1
0
mirror of git://f0xx.org/ac/ac-be-auth synced 2026-07-29 02:17:43 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:35 +02:00
commit 68984c499e
12 changed files with 466 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-be-auth
Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-auth`

19
composer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "androidcast/be-auth",
"description": "Login/register shell",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-web": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-web"
}
]
}

View File

@@ -0,0 +1,66 @@
(function () {
'use strict';
function boot() {
var form = document.querySelector('form.login-card');
var input = document.getElementById('twofa-code-input');
if (!form || !input) {
return;
}
input.focus();
input.select();
function digitsOnly(val) {
return String(val || '').replace(/\D/g, '').slice(0, 6);
}
function maybeSubmit() {
if (input.value.length === 6) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
}
input.addEventListener('input', function () {
var next = digitsOnly(input.value);
if (next !== input.value) {
input.value = next;
}
maybeSubmit();
});
input.addEventListener('paste', function (ev) {
ev.preventDefault();
var text = (ev.clipboardData && ev.clipboardData.getData('text')) || '';
input.value = digitsOnly(text);
maybeSubmit();
});
document.addEventListener('keydown', function (ev) {
if (!/^[0-9]$/.test(ev.key)) {
return;
}
var ae = document.activeElement;
if (ae === input) {
return;
}
var tag = ae && ae.tagName ? ae.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (ae && ae.isContentEditable)) {
return;
}
ev.preventDefault();
input.focus();
input.value = digitsOnly(input.value + ev.key);
maybeSubmit();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
require __DIR__ . '/../views/layout.php';

View File

@@ -0,0 +1,69 @@
<?php
$bp = Auth::basePath();
$user = Auth::user();
$hasTotp = AuthFactors::hasTotp((int) ($user['id'] ?? 0));
$issuer = (string) cfg('app_name', 'Android Cast');
$account = (string) ($user['username'] ?? '');
$otpUri = $totpSecret ? AuthTotp::provisioningUri($totpSecret, $account, $issuer) : '';
$qrUrl = $otpUri !== '' ? AuthTotp::qrImageUrl($otpUri) : '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account security — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-card" style="max-width:28rem">
<h1 data-i18n="security.title">Account security</h1>
<p class="muted"><?= h($account) ?></p>
<?php if (!empty($securityError)): ?>
<div class="alert"><?= h($securityError) ?></div>
<?php endif; ?>
<?php if (!empty($securitySuccess)): ?>
<div class="alert alert--ok"><?= h($securitySuccess) ?></div>
<?php endif; ?>
<?php if ($hasTotp): ?>
<p data-i18n="security.totp_enabled">Authenticator app is enrolled.</p>
<form method="post" action="<?= h($bp) ?>/account-security" onsubmit="return confirm('Remove authenticator?');">
<input type="hidden" name="action" value="remove_totp">
<button type="submit" class="btn btn--secondary" data-i18n="security.remove_totp">Remove authenticator</button>
</form>
<?php elseif ($totpSecret): ?>
<p data-i18n="security.scan_qr">Scan this QR code with your authenticator app, then enter a code to confirm.</p>
<?php if ($qrUrl): ?>
<p style="text-align:center"><img src="<?= h($qrUrl) ?>" width="200" height="200" alt="TOTP QR" loading="lazy"></p>
<?php endif; ?>
<details class="hint">
<summary data-i18n="security.manual_secret">Cant scan?</summary>
<code style="word-break:break-all"><?= h($totpSecret) ?></code>
</details>
<form method="post" action="<?= h($bp) ?>/account-security">
<input type="hidden" name="action" value="confirm_totp">
<label><span data-i18n="twofa.code">Authentication code</span>
<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required></label>
<button type="submit" data-i18n="security.confirm_totp">Confirm enrollment</button>
</form>
<?php else: ?>
<p data-i18n="security.totp_intro">Protect your account with a 6-digit authenticator app (recommended for alpha).</p>
<form method="post" action="<?= h($bp) ?>/account-security">
<input type="hidden" name="action" value="start_totp">
<button type="submit" data-i18n="security.start_totp">Set up authenticator</button>
</form>
<?php endif; ?>
<p class="hint" style="margin-top:1.5rem"><a href="<?= h($bp) ?>/" data-i18n="security.back_console">Back to console</a></p>
</div>
<?php platform_render_footer(); ?>
</body>
</html>

58
views/login.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/*
* package examples/crash_reporter/backend/views/login.php
* login.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 27 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 9bef983f4d34cb790922cda355ccf5d14b565b29273d2ebfe48b3173cd2e8f9f
*/
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<?php AnalyticsHead::render('crashes_login'); ?>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($auth) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous issue reports</p>
<?php if (!empty($loginError)): ?>
<div class="alert" data-i18n="login.error"><?= h($loginError) ?></div>
<?php endif; ?>
<label><span data-i18n="login.username">Username</span><input name="username" autocomplete="username" required></label>
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" data-i18n="login.submit">Sign in</button>
<p class="hint" data-i18n="login.hint_default">Default: admin / admin</p>
<p class="hint"><a href="<?= h($auth) ?>/register" data-i18n="login.register">Register</a></p>
</form>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
?>
<div class="console-home">
<div class="toolbar reports-toolbar console-home-toolbar">
<h1 data-i18n="home.title">Console</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
</div>
</div>
<p class="console-home-intro" data-i18n="home.intro">Issue triage, tickets, and session metrics — pick a workspace below.</p>
<div class="cards cards--lift console-home-cards">
<a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<h2 data-i18n="home.card_issues">Issues</h2>
<p class="muted" data-i18n="home.card_issues_desc">Browse and triage issues, tags, and fingerprints.</p>
</a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-issue-btn">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<h2 data-i18n="issues.new">New issue</h2>
<p class="muted" data-i18n="home.card_new_issue_desc">Log a new issue or task in one step.</p>
</button>
<a href="<?= h($bp) ?>/?view=tickets" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="nav.tickets">Tickets</h2>
<p class="muted" data-i18n="home.card_tickets_desc">Roadmap tasks, QA items, and workflow tags.</p>
</a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-ticket-btn">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="tickets.new">New ticket</h2>
<p class="muted" data-i18n="home.card_new_ticket_desc">Create a roadmap or QA ticket in one step.</p>
</button>
<a href="<?= h($proj) ?>/graphs/" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<h2 data-i18n="home.card_graphs">Graphs</h2>
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, issues, and device activity over time.</p>
</a>
<?php if (Rbac::can('short_links_view')): ?>
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<h2 data-i18n="home.card_short_links">Short links</h2>
<p class="muted" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</p>
</a>
<?php endif; ?>
</div>
<h2 class="console-home-more" data-i18n="home.more">All services</h2>
<?php $skip_home_link = true; require __DIR__ . '/console_quick_links.php'; ?>
</div>

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* Horizontal service links with the same icons as the left nav (8px gap).
* @var string|null $extra_class optional class on <nav>
* @var bool $skip_home_link omit "Console home" (use on home view)
*/
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
$items = [];
if (empty($skip_home_link)) {
$items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home'];
}
$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports'];
$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
if (Rbac::can('remote_access_view')) {
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
}
if (Rbac::can('short_links_view')) {
$items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link'];
}
if (Rbac::canManageRbac()) {
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
}
$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'];
$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder'];
$items[] = ['Hub', $proj . '/', 'nav-icon--home'];
?>
<nav class="<?= h($navClass) ?>" aria-label="Related services">
<?php foreach ($items as [$label, $href, $icon]): ?>
<a href="<?= h($href) ?>" class="console-quick-link">
<span class="nav-icon <?= h($icon) ?>" aria-hidden="true"></span>
<span><?= h($label) ?></span>
</a>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,15 @@
<?php declare(strict_types=1); ?>
<div id="cookie-consent-banner" class="cookie-consent" hidden role="dialog" aria-labelledby="cookie-consent-title" aria-modal="false">
<div class="cookie-consent-inner">
<p id="cookie-consent-title" class="cookie-consent-title">Cookies on AndroidCast</p>
<p class="cookie-consent-text muted">
We use necessary session cookies to sign you in. With your consent we also load analytics to improve the service.
You can change this later in your browser by clearing site data.
</p>
<div class="cookie-consent-actions">
<button type="button" class="btn btn--primary" id="cookie-consent-all">Accept all</button>
<button type="button" class="btn" id="cookie-consent-necessary">Necessary only</button>
<button type="button" class="btn btn--ghost" id="cookie-consent-reject">Reject optional</button>
</div>
</div>
</div>

53
views/register.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Register — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<?php AnalyticsHead::render('crashes_register'); ?>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($auth) ?>/register">
<h1 data-i18n="register.title">Create account</h1>
<p class="muted" data-i18n="register.hint">We will email a verification link before you can sign in.</p>
<?php if (!empty($registerError)): ?>
<div class="alert"><?= h($registerError) ?></div>
<?php endif; ?>
<?php if (!empty($registerSuccess)): ?>
<div class="alert alert--ok"><?= h($registerSuccess) ?></div>
<?php endif; ?>
<label><span data-i18n="register.email">Email</span>
<input name="email" type="email" autocomplete="email" required value="<?= h($registerEmail ?? '') ?>"></label>
<label><span data-i18n="register.username">Username</span>
<input name="username" autocomplete="username" value="<?= h($registerUsername ?? '') ?>"></label>
<label><span data-i18n="register.password">Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit" data-i18n="register.submit">Register</button>
<p class="hint"><a href="<?= h($auth) ?>/login" data-i18n="register.back_login">Back to sign in</a></p>
</form>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
$twofaQr = AuthTwoFactorPage::qrPayload();
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Two-factor — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h($bp) ?>/assets/js/two_factor.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($auth) ?>/two-factor" id="twofa-form">
<h1 data-i18n="twofa.title">Two-factor authentication</h1>
<p class="muted" data-i18n="twofa.hint">Enter the 6-digit code from your authenticator app.</p>
<?php if (!empty($twofaError)): ?>
<div class="alert" data-i18n="twofa.error"><?= h($twofaError) ?></div>
<?php endif; ?>
<?php if (is_array($twofaQr) && !empty($twofaQr['qr_url'])): ?>
<figure class="twofa-qr-wrap">
<img src="<?= h((string) $twofaQr['qr_url']) ?>" width="160" height="160" alt="QR code for mobile sign-in link" class="twofa-qr">
<figcaption class="muted">Scan to open the hub on your phone<?php if (!empty($twofaQr['short_url'])): ?> · <a href="<?= h((string) $twofaQr['short_url']) ?>" rel="noopener"><?= h((string) $twofaQr['short_url']) ?></a><?php endif; ?></figcaption>
</figure>
<?php endif; ?>
<label><span data-i18n="twofa.code">Authentication code</span>
<input id="twofa-code-input" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label>
<button type="submit" data-i18n="twofa.submit">Continue</button>
<p class="hint"><a href="<?= h($auth) ?>/login" data-i18n="twofa.cancel">Cancel</a></p>
</form>
<?php platform_render_footer(); ?>
</body>
</html>

33
views/verify_email.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
$bp = Auth::basePath();
$auth = Auth::authUrl();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verify email — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-card">
<h1 data-i18n="verify.title">Email verification</h1>
<?php if (!empty($verifyOk)): ?>
<p class="alert alert--ok" data-i18n="verify.ok">Your email is verified. You can sign in and enroll two-factor authentication.</p>
<p><a class="btn" href="<?= h($auth) ?>/login" data-i18n="verify.sign_in">Sign in</a></p>
<?php else: ?>
<p class="alert" data-i18n="verify.fail"><?= h($verifyError ?? 'Invalid or expired link.') ?></p>
<p class="hint"><a href="<?= h($auth) ?>/register" data-i18n="verify.register_again">Register again</a></p>
<?php endif; ?>
</div>
<?php platform_render_footer(); ?>
</body>
</html>