270 lines
11 KiB
HTML
270 lines
11 KiB
HTML
<main class="shell py-4 py-lg-5">
|
|
<div class="container-xl">
|
|
@if (!embeddedMode) {
|
|
<section class="hero-panel mb-4 mb-lg-5 p-4 p-lg-5">
|
|
<div class="d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-4">
|
|
<div class="hero-copy">
|
|
<div class="d-flex align-items-center justify-content-between gap-3">
|
|
<span class="eyebrow mb-0">WebRTC Private Chat</span>
|
|
<button
|
|
class="theme-toggle"
|
|
type="button"
|
|
(click)="cycleTheme()"
|
|
[attr.aria-label]="'Theme mode: ' + theme.mode() + '. Switch to ' + theme.nextMode()"
|
|
[title]="'Theme mode: ' + theme.mode() + '. Click for ' + theme.nextMode()"
|
|
>
|
|
<span class="theme-toggle-icon" aria-hidden="true">{{ theme.emoji() }}</span>
|
|
<span class="theme-toggle-label">{{ theme.mode() }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (session.currentUser(); as user) {
|
|
<div class="session-card p-3 p-lg-4">
|
|
<div class="text-uppercase small text-secondary mb-2">Signed in</div>
|
|
<div class="h4 mb-1">{{ user.displayName }}</div>
|
|
<div class="text-secondary mb-3">{{ user.username }}</div>
|
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
|
<button class="btn btn-accent w-100 mb-2" type="button" (click)="openChatUi()">
|
|
Open chat UI
|
|
</button>
|
|
@if (session.isApprovalAdmin()) {
|
|
<a class="btn btn-outline-light w-100 mb-2" routerLink="/approvals">Approve accounts</a>
|
|
}
|
|
<button class="btn btn-outline-light w-100" type="button" (click)="logout()">Log out</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@if (!session.currentUser()) {
|
|
@if (embeddedMode) {
|
|
<section class="panel p-4 p-lg-5 text-center">
|
|
<h2 class="h3 mb-3">Open Settings to sign in</h2>
|
|
<p class="text-secondary mb-0">
|
|
This embedded client expects authentication and backend configuration from the native app settings.
|
|
</p>
|
|
</section>
|
|
} @else {
|
|
<section class="row g-4 align-items-stretch">
|
|
<div class="col-lg-6">
|
|
<div class="panel p-4 h-100">
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
|
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
|
|
</div>
|
|
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label" for="serverUrl">Backend URL</label>
|
|
<input
|
|
id="serverUrl"
|
|
name="serverUrl"
|
|
class="form-control form-control-lg"
|
|
[(ngModel)]="serverUrl"
|
|
placeholder="http://localhost:3000"
|
|
/>
|
|
</div>
|
|
|
|
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
|
<button
|
|
class="btn"
|
|
[class.btn-primary]="authMode === 'login'"
|
|
[class.btn-outline-primary]="authMode !== 'login'"
|
|
type="button"
|
|
(click)="authMode = 'login'"
|
|
>
|
|
Log in
|
|
</button>
|
|
<button
|
|
class="btn"
|
|
[class.btn-primary]="authMode === 'register'"
|
|
[class.btn-outline-primary]="authMode !== 'register'"
|
|
type="button"
|
|
(click)="authMode = 'register'"
|
|
>
|
|
Register
|
|
</button>
|
|
</div>
|
|
|
|
<form class="d-grid gap-3" (ngSubmit)="submitAuth()">
|
|
@if (authMode === 'register') {
|
|
<div>
|
|
<label class="form-label" for="displayName">Display name</label>
|
|
<input
|
|
id="displayName"
|
|
name="displayName"
|
|
class="form-control form-control-lg"
|
|
[(ngModel)]="displayName"
|
|
placeholder="Operator One"
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
<div>
|
|
<label class="form-label" for="username">Username</label>
|
|
<input
|
|
id="username"
|
|
name="username"
|
|
class="form-control form-control-lg"
|
|
[(ngModel)]="username"
|
|
placeholder="alice"
|
|
[attr.autocomplete]="authMode === 'login' ? 'username webauthn' : 'username'"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="form-label" for="password">Password</label>
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
class="form-control form-control-lg"
|
|
[(ngModel)]="password"
|
|
placeholder="At least 8 characters"
|
|
autocomplete="current-password"
|
|
/>
|
|
</div>
|
|
|
|
<button class="btn btn-accent btn-lg mt-2" type="submit">
|
|
{{ authMode === 'login' ? 'Enter chat' : 'Create account' }}
|
|
</button>
|
|
|
|
@if (authMode === 'login' && session.webAuthnSupported()) {
|
|
<div class="text-center small text-secondary mt-1">or</div>
|
|
<button class="btn btn-outline-light btn-lg" type="button" (click)="loginWithAccessKey()">
|
|
🔑 Use access key
|
|
</button>
|
|
<div class="small text-secondary">
|
|
Leave the username blank to choose from discoverable passkeys, or enter it to target one account.
|
|
</div>
|
|
}
|
|
</form>
|
|
|
|
@if (session.error()) {
|
|
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
|
}
|
|
|
|
@if (session.notice()) {
|
|
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6">
|
|
<div class="panel panel-muted p-4 h-100">
|
|
<h2 class="h3 mb-3">Transport model</h2>
|
|
<div class="info-rail d-grid gap-3">
|
|
<article>
|
|
<div class="small text-uppercase text-secondary mb-2">1. Authenticate</div>
|
|
<p class="mb-0">Register or log in against the Fastify API to receive a JWT.</p>
|
|
</article>
|
|
<article>
|
|
<div class="small text-uppercase text-secondary mb-2">2. Discover peers</div>
|
|
<p class="mb-0">Open the WebSocket signaling session and receive the online peer list.</p>
|
|
</article>
|
|
<article>
|
|
<div class="small text-uppercase text-secondary mb-2">3. Exchange data directly</div>
|
|
<p class="mb-0">Create a WebRTC data channel and send text, JSON, or files peer-to-peer.</p>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
}
|
|
} @else {
|
|
<section class="row g-4 align-items-stretch">
|
|
<div class="col-lg-5">
|
|
<div class="panel p-4 h-100">
|
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
|
<div>
|
|
<h2 class="h3 mb-1">Connection settings</h2>
|
|
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
|
|
</div>
|
|
@if (session.isApprovalAdmin()) {
|
|
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
|
}
|
|
</div>
|
|
|
|
@if (!embeddedMode) {
|
|
<label class="form-label" for="connectedServerUrl">Backend URL</label>
|
|
<div class="input-group mb-3">
|
|
<input
|
|
id="connectedServerUrl"
|
|
class="form-control"
|
|
[(ngModel)]="serverUrl"
|
|
(blur)="applyServerUrl()"
|
|
/>
|
|
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
|
|
</div>
|
|
} @else {
|
|
<div class="empty-state p-4 text-center text-secondary">
|
|
Backend settings are managed by the native app in embedded mode.
|
|
</div>
|
|
}
|
|
|
|
<div class="small status-pill mt-3">{{ session.status() }}</div>
|
|
|
|
@if (session.error()) {
|
|
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
|
}
|
|
|
|
@if (session.notice()) {
|
|
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-7">
|
|
<div class="panel p-4 h-100">
|
|
<section class="access-key-panel">
|
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
|
<div>
|
|
<h3 class="h5 mb-1">Access keys</h3>
|
|
<p class="small text-secondary mb-0">Register one or more WebAuthn credentials for this account.</p>
|
|
</div>
|
|
<span class="badge rounded-pill text-bg-dark">{{ session.accessKeys().length }}</span>
|
|
</div>
|
|
|
|
@if (!embeddedMode && session.webAuthnSupported()) {
|
|
<div class="input-group mb-3">
|
|
<input
|
|
class="form-control"
|
|
[(ngModel)]="accessKeyLabel"
|
|
placeholder="Laptop passkey"
|
|
/>
|
|
<button class="btn btn-outline-light" type="button" (click)="registerAccessKey()">Add key</button>
|
|
</div>
|
|
} @else if (!embeddedMode) {
|
|
<div class="alert alert-warning mb-3">This browser does not expose WebAuthn registration APIs.</div>
|
|
} @else {
|
|
<div class="empty-state p-3 text-center text-secondary mb-3">
|
|
Access keys are managed through the native app in embedded mode.
|
|
</div>
|
|
}
|
|
|
|
<div class="d-grid gap-2">
|
|
@if (session.accessKeys().length === 0) {
|
|
<div class="empty-state p-3 text-center text-secondary">No access keys registered yet.</div>
|
|
}
|
|
|
|
@for (key of session.accessKeys(); track key.id) {
|
|
<article class="access-key-card p-3">
|
|
<div class="fw-semibold">{{ key.label }}</div>
|
|
<div class="small text-secondary">Device: {{ key.deviceType }}{{ key.backedUp ? ' / backed up' : '' }}</div>
|
|
<div class="small text-secondary">Transports: {{ key.transports.length > 0 ? key.transports.join(', ') : 'unspecified' }}</div>
|
|
<div class="small text-secondary">Added: {{ key.createdAt | date: 'medium' }}</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
}
|
|
</div>
|
|
</main>
|