More functional and typesafe frontend

This commit is contained in:
lewis
2026-01-04 23:43:11 +02:00
parent 938a9841b6
commit a331f5e56f
60 changed files with 5906 additions and 2482 deletions
+228 -434
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -16,18 +16,19 @@
"@atcute/crypto": "^2.3.0",
"@atcute/did-plc": "^0.3.1",
"@atcute/multibase": "^1.1.6",
"@noble/secp256k1": "^2.1.0",
"multiformats": "^13.3.1",
"svelte-i18n": "^4.0.1"
"@noble/secp256k1": "^3.0.0",
"multiformats": "^13.4.2",
"svelte-i18n": "^4.0.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@testing-library/user-event": "^14.5.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"jsdom": "^25.0.1",
"svelte": "^5.0.0",
"vite": "^6.0.0",
"vitest": "^2.1.8"
"svelte": "^5.46.1",
"vite": "^7.3.0",
"vitest": "^4.0.16"
}
}
+7 -11
View File
@@ -4,6 +4,7 @@
import { initServerConfig } from './lib/serverConfig.svelte'
import { initI18n } from './lib/i18n'
import { isLoading as i18nLoading } from 'svelte-i18n'
import Toast from './components/Toast.svelte'
import Login from './routes/Login.svelte'
import Register from './routes/Register.svelte'
import RegisterPasskey from './routes/RegisterPasskey.svelte'
@@ -36,7 +37,7 @@
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
initI18n()
const auth = getAuthState()
const auth = $derived(getAuthState())
let oauthCallbackPending = $state(hasOAuthCallback())
@@ -59,10 +60,10 @@
})
$effect(() => {
if (auth.loading) return
if (auth.kind === 'loading') return
const path = getCurrentPath()
if (path === '/') {
if (auth.session) {
if (auth.kind === 'authenticated') {
navigate('/dashboard', true)
} else {
navigate('/login', true)
@@ -142,14 +143,13 @@
</script>
<main>
{#if auth.loading || $i18nLoading || oauthCallbackPending}
<div class="loading">
<p>Loading...</p>
</div>
{#if auth.kind === 'loading' || $i18nLoading || oauthCallbackPending}
<div class="loading"></div>
{:else}
<CurrentComponent />
{/if}
</main>
<Toast />
<style>
main {
@@ -157,10 +157,6 @@
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--text-secondary);
}
</style>
+18 -49
View File
@@ -2,6 +2,12 @@
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import {
prepareRequestOptions,
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
interface Props {
show: boolean
@@ -12,7 +18,13 @@
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
const session = $derived(getSession())
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
let password = $state('')
let totpCode = $state('')
@@ -37,40 +49,9 @@
}
})
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handlePasswordSubmit(e: Event) {
e.preventDefault()
if (!auth.session || !password) return
if (!session || !password) return
loading = true
error = ''
try {
@@ -91,7 +72,7 @@
async function handleTotpSubmit(e: Event) {
e.preventDefault()
if (!auth.session || !totpCode) return
if (!session || !totpCode) return
loading = true
error = ''
try {
@@ -111,7 +92,7 @@
}
async function handlePasskeyAuth() {
if (!auth.session) return
if (!session) return
if (!window.PublicKeyCredential) {
error = 'Passkeys are not supported in this browser'
return
@@ -125,7 +106,7 @@
return
}
const { options } = await api.reauthPasskeyStart(token)
const publicKeyOptions = prepareAuthOptions(options)
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
})
@@ -133,19 +114,7 @@
error = 'Passkey authentication was cancelled'
return
}
const pkCredential = credential as PublicKeyCredential
const response = pkCredential.response as AuthenticatorAssertionResponse
const credentialResponse = {
id: pkCredential.id,
type: pkCredential.type,
rawId: arrayBufferToBase64Url(pkCredential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
},
}
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
await api.reauthPasskeyFinish(token, credentialResponse)
show = false
onSuccess()
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
type Variant = 'line' | 'circle' | 'card'
type Size = 'tiny' | 'short' | 'medium' | 'full'
interface Props {
variant?: Variant
size?: Size
lines?: number
class?: string
}
let { variant = 'line', size = 'full', lines = 1, class: className = '' }: Props = $props()
</script>
{#if variant === 'card'}
<div class="skeleton-card {className}">
<div class="skeleton-header">
<div class="skeleton-line short"></div>
<div class="skeleton-line tiny"></div>
</div>
{#each Array(lines) as _}
<div class="skeleton-line"></div>
{/each}
<div class="skeleton-line medium"></div>
</div>
{:else if variant === 'circle'}
<div class="skeleton-circle {className}"></div>
{:else}
{#each Array(lines) as _, i}
<div class="skeleton-line {size} {className}" class:last={i === lines - 1}></div>
{/each}
{/if}
<style>
.skeleton-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.skeleton-header {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.skeleton-line {
height: 14px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
margin-bottom: var(--space-1);
}
.skeleton-line.last {
margin-bottom: 0;
}
.skeleton-line.tiny { width: 50px; }
.skeleton-line.short { width: 80px; }
.skeleton-line.medium { width: 60%; }
.skeleton-line.full { width: 100%; }
.skeleton-circle {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { getToasts, dismissToast, type Toast } from '../lib/toast.svelte'
const toasts = $derived(getToasts())
function handleDismiss(id: number) {
dismissToast(id)
}
function getIcon(type: Toast['type']): string {
switch (type) {
case 'success':
return '✓'
case 'error':
return '!'
case 'warning':
return '⚠'
case 'info':
return 'i'
}
}
</script>
{#if toasts.length > 0}
<div class="toast-container" role="region" aria-label="Notifications">
{#each toasts as toast (toast.id)}
<div
class="toast toast-{toast.type}"
class:dismissing={toast.dismissing}
role="alert"
aria-live="polite"
>
<span class="toast-icon">{getIcon(toast.type)}</span>
<span class="toast-message">{toast.message}</span>
<button
type="button"
class="toast-dismiss"
onclick={() => handleDismiss(toast.id)}
aria-label="Dismiss notification"
>
x
</button>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
top: var(--space-6);
right: var(--space-6);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: min(400px, calc(100vw - var(--space-12)));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
pointer-events: auto;
animation: toast-in 0.1s ease-out;
}
.toast.dismissing {
animation: toast-out 0.15s ease-in forwards;
}
@keyframes toast-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes toast-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
.toast-success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.toast-error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
}
.toast-warning {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
}
.toast-info {
background: var(--accent-muted);
border: 1px solid var(--accent);
color: var(--text-primary);
}
.toast-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: var(--text-xs);
font-weight: var(--font-bold);
}
.toast-success .toast-icon {
background: var(--success-text);
color: var(--success-bg);
}
.toast-error .toast-icon {
background: var(--error-text);
color: var(--error-bg);
}
.toast-warning .toast-icon {
background: var(--warning-text);
color: var(--warning-bg);
}
.toast-info .toast-icon {
background: var(--accent);
color: var(--bg-card);
}
.toast-message {
flex: 1;
font-size: var(--text-sm);
line-height: 1.4;
}
.toast-dismiss {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
opacity: 0.6;
font-size: var(--text-sm);
line-height: 1;
color: inherit;
border-radius: var(--radius-sm);
}
.toast-dismiss:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
@media (max-width: 480px) {
.toast-container {
top: var(--space-4);
right: var(--space-4);
left: var(--space-4);
max-width: none;
}
}
</style>
+345
View File
@@ -0,0 +1,345 @@
import { z } from 'zod'
import { ok, err, type Result } from './types/result'
import { ApiError } from './api'
import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded'
import {
sessionSchema,
serverDescriptionSchema,
appPasswordSchema,
createdAppPasswordSchema,
listSessionsResponseSchema,
totpStatusSchema,
totpSecretSchema,
enableTotpResponseSchema,
listPasskeysResponseSchema,
listTrustedDevicesResponseSchema,
reauthStatusSchema,
notificationPrefsSchema,
didDocumentSchema,
repoDescriptionSchema,
listRecordsResponseSchema,
recordResponseSchema,
createRecordResponseSchema,
serverStatsSchema,
serverConfigSchema,
passwordStatusSchema,
successResponseSchema,
legacyLoginPreferenceSchema,
accountInfoSchema,
searchAccountsResponseSchema,
listBackupsResponseSchema,
createBackupResponseSchema,
type ValidatedSession,
type ValidatedServerDescription,
type ValidatedListSessionsResponse,
type ValidatedTotpStatus,
type ValidatedTotpSecret,
type ValidatedEnableTotpResponse,
type ValidatedListPasskeysResponse,
type ValidatedListTrustedDevicesResponse,
type ValidatedReauthStatus,
type ValidatedNotificationPrefs,
type ValidatedDidDocument,
type ValidatedRepoDescription,
type ValidatedListRecordsResponse,
type ValidatedRecordResponse,
type ValidatedCreateRecordResponse,
type ValidatedServerStats,
type ValidatedServerConfig,
type ValidatedPasswordStatus,
type ValidatedSuccessResponse,
type ValidatedLegacyLoginPreference,
type ValidatedAccountInfo,
type ValidatedSearchAccountsResponse,
type ValidatedListBackupsResponse,
type ValidatedCreateBackupResponse,
type ValidatedCreatedAppPassword,
type ValidatedAppPassword,
} from './types/schemas'
const API_BASE = '/xrpc'
interface XrpcOptions {
method?: 'GET' | 'POST'
params?: Record<string, string>
body?: unknown
token?: string
}
class ValidationError extends Error {
constructor(
public issues: z.ZodIssue[],
message: string = 'API response validation failed'
) {
super(message)
this.name = 'ValidationError'
}
}
async function xrpcValidated<T>(
method: string,
schema: z.ZodType<T>,
options?: XrpcOptions
): Promise<Result<T, ApiError | ValidationError>> {
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
let url = `${API_BASE}/${method}`
if (params) {
const searchParams = new URLSearchParams(params)
url += `?${searchParams}`
}
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (body) {
headers['Content-Type'] = 'application/json'
}
try {
const res = await fetch(url, {
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: 'Unknown',
message: res.statusText,
}))
return err(new ApiError(res.status, errData.error, errData.message))
}
const data = await res.json()
const parsed = schema.safeParse(data)
if (!parsed.success) {
return err(new ValidationError(parsed.error.issues))
}
return ok(parsed.data)
} catch (e) {
if (e instanceof ApiError || e instanceof ValidationError) {
return err(e)
}
return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e)))
}
}
export const validatedApi = {
getSession(token: AccessToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token })
},
refreshSession(refreshJwt: RefreshToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, {
method: 'POST',
token: refreshJwt,
})
},
createSession(
identifier: string,
password: string
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createSession', sessionSchema, {
method: 'POST',
body: { identifier, password },
})
},
describeServer(): Promise<Result<ValidatedServerDescription, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema)
},
listAppPasswords(
token: AccessToken
): Promise<Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>> {
return xrpcValidated(
'com.atproto.server.listAppPasswords',
z.object({ passwords: z.array(appPasswordSchema) }),
{ token }
)
},
createAppPassword(
token: AccessToken,
name: string,
scopes?: string
): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, {
method: 'POST',
token,
body: { name, scopes },
})
},
listSessions(token: AccessToken): Promise<Result<ValidatedListSessionsResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token })
},
getTotpStatus(token: AccessToken): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token })
},
createTotpSecret(token: AccessToken): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, {
method: 'POST',
token,
})
},
enableTotp(
token: AccessToken,
code: string
): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, {
method: 'POST',
token,
body: { code },
})
},
listPasskeys(token: AccessToken): Promise<Result<ValidatedListPasskeysResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token })
},
listTrustedDevices(
token: AccessToken
): Promise<Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token })
},
getReauthStatus(token: AccessToken): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> {
return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token })
},
getNotificationPrefs(
token: AccessToken
): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> {
return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token })
},
getDidDocument(token: AccessToken): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> {
return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token })
},
describeRepo(
token: AccessToken,
repo: Did
): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, {
token,
params: { repo },
})
},
listRecords(
token: AccessToken,
repo: Did,
collection: Nsid,
options?: { limit?: number; cursor?: string; reverse?: boolean }
): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> {
const params: Record<string, string> = { repo, collection }
if (options?.limit) params.limit = String(options.limit)
if (options?.cursor) params.cursor = options.cursor
if (options?.reverse) params.reverse = 'true'
return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, {
token,
params,
})
},
getRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey
): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, {
token,
params: { repo, collection, rkey },
})
},
createRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
record: unknown,
rkey?: Rkey
): Promise<Result<ValidatedCreateRecordResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, {
method: 'POST',
token,
body: { repo, collection, record, rkey },
})
},
getServerStats(token: AccessToken): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> {
return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token })
},
getServerConfig(): Promise<Result<ValidatedServerConfig, ApiError | ValidationError>> {
return xrpcValidated('_server.getConfig', serverConfigSchema)
},
getPasswordStatus(token: AccessToken): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> {
return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token })
},
changePassword(
token: AccessToken,
currentPassword: string,
newPassword: string
): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.changePassword', successResponseSchema, {
method: 'POST',
token,
body: { currentPassword, newPassword },
})
},
getLegacyLoginPreference(
token: AccessToken
): Promise<Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>> {
return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token })
},
getAccountInfo(
token: AccessToken,
did: Did
): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, {
token,
params: { did },
})
},
searchAccounts(
token: AccessToken,
options?: { handle?: string; cursor?: string; limit?: number }
): Promise<Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>> {
const params: Record<string, string> = {}
if (options?.handle) params.handle = options.handle
if (options?.cursor) params.cursor = options.cursor
if (options?.limit) params.limit = String(options.limit)
return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, {
token,
params,
})
},
listBackups(token: AccessToken): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> {
return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token })
},
createBackup(token: AccessToken): Promise<Result<ValidatedCreateBackupResponse, ApiError | ValidationError>> {
return xrpcValidated('_backup.createBackup', createBackupResponseSchema, {
method: 'POST',
token,
})
},
}
export { ValidationError }
+1165 -766
View File
File diff suppressed because it is too large Load Diff
+425 -236
View File
@@ -1,11 +1,23 @@
import {
api,
ApiError,
typedApi,
type CreateAccountParams,
type CreateAccountResult,
type Session,
setTokenRefreshCallback,
} from "./api";
import type { Session } from "./types/api";
import {
type Did,
type Handle,
type AccessToken,
type RefreshToken,
unsafeAsDid,
unsafeAsHandle,
unsafeAsAccessToken,
unsafeAsRefreshToken,
} from "./types/branded";
import { type Result, ok, err, isOk, isErr, map } from "./types/result";
import { assertNever } from "./types/exhaustive";
import {
checkForOAuthCallback,
clearOAuthCallbackParams,
@@ -15,39 +27,184 @@ import {
} from "./oauth";
import { setLocale, type SupportedLocale } from "./i18n";
function applyLocaleFromSession(
sessionInfo: { preferredLocale?: string | null },
) {
const STORAGE_KEY = "tranquil_pds_session";
const ACCOUNTS_KEY = "tranquil_pds_accounts";
export interface SavedAccount {
readonly did: Did;
readonly handle: Handle;
readonly accessJwt: AccessToken;
readonly refreshJwt: RefreshToken;
}
export type AuthError =
| { readonly type: "network"; readonly message: string }
| { readonly type: "unauthorized"; readonly message: string }
| { readonly type: "validation"; readonly message: string }
| { readonly type: "oauth"; readonly message: string }
| { readonly type: "unknown"; readonly message: string };
function toAuthError(e: unknown): AuthError {
if (e instanceof ApiError) {
if (e.status === 401) {
return { type: "unauthorized", message: e.message };
}
return { type: "validation", message: e.message };
}
if (e instanceof Error) {
if (e.message.includes("network") || e.message.includes("fetch")) {
return { type: "network", message: e.message };
}
return { type: "unknown", message: e.message };
}
return { type: "unknown", message: "An unknown error occurred" };
}
type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error";
export type AuthState =
| {
readonly kind: "unauthenticated";
readonly savedAccounts: readonly SavedAccount[];
}
| {
readonly kind: "loading";
readonly savedAccounts: readonly SavedAccount[];
readonly previousSession: Session | null;
}
| {
readonly kind: "authenticated";
readonly session: Session;
readonly savedAccounts: readonly SavedAccount[];
}
| {
readonly kind: "error";
readonly error: AuthError;
readonly savedAccounts: readonly SavedAccount[];
};
function createUnauthenticated(
savedAccounts: readonly SavedAccount[],
): AuthState {
return { kind: "unauthenticated", savedAccounts };
}
function createLoading(
savedAccounts: readonly SavedAccount[],
previousSession: Session | null = null,
): AuthState {
return { kind: "loading", savedAccounts, previousSession };
}
function createAuthenticated(
session: Session,
savedAccounts: readonly SavedAccount[],
): AuthState {
return { kind: "authenticated", session, savedAccounts };
}
function createError(
error: AuthError,
savedAccounts: readonly SavedAccount[],
): AuthState {
return { kind: "error", error, savedAccounts };
}
const state = $state<{ current: AuthState }>({
current: createLoading([]),
});
function applyLocaleFromSession(sessionInfo: {
preferredLocale?: string | null;
}): void {
if (sessionInfo.preferredLocale) {
setLocale(sessionInfo.preferredLocale as SupportedLocale);
}
}
const STORAGE_KEY = "tranquil_pds_session";
const ACCOUNTS_KEY = "tranquil_pds_accounts";
export interface SavedAccount {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
function sessionToSavedAccount(session: Session): SavedAccount {
return {
did: unsafeAsDid(session.did),
handle: unsafeAsHandle(session.handle),
accessJwt: unsafeAsAccessToken(session.accessJwt),
refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
};
}
interface AuthState {
session: Session | null;
loading: boolean;
error: string | null;
savedAccounts: SavedAccount[];
interface StoredSession {
readonly did: string;
readonly handle: string;
readonly accessJwt: string;
readonly refreshJwt: string;
readonly email?: string;
readonly emailConfirmed?: boolean;
readonly preferredChannel?: string;
readonly preferredChannelVerified?: boolean;
readonly preferredLocale?: string | null;
}
const state = $state<AuthState>({
session: null,
loading: true,
error: null,
savedAccounts: [],
});
function parseStoredSession(json: string): Result<StoredSession, Error> {
try {
const parsed = JSON.parse(json);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof parsed.did === "string" &&
typeof parsed.handle === "string" &&
typeof parsed.accessJwt === "string" &&
typeof parsed.refreshJwt === "string"
) {
return ok(parsed as StoredSession);
}
return err(new Error("Invalid session format"));
} catch (e) {
return err(e instanceof Error ? e : new Error("Failed to parse session"));
}
}
function saveSession(session: Session | null) {
function parseStoredAccounts(json: string): Result<SavedAccount[], Error> {
try {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) {
return err(new Error("Invalid accounts format"));
}
const accounts: SavedAccount[] = parsed
.filter(
(a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } =>
typeof a === "object" &&
a !== null &&
typeof a.did === "string" &&
typeof a.handle === "string" &&
typeof a.accessJwt === "string" &&
typeof a.refreshJwt === "string",
)
.map((a) => ({
did: unsafeAsDid(a.did),
handle: unsafeAsHandle(a.handle),
accessJwt: unsafeAsAccessToken(a.accessJwt),
refreshJwt: unsafeAsRefreshToken(a.refreshJwt),
}));
return ok(accounts);
} catch (e) {
return err(e instanceof Error ? e : new Error("Failed to parse accounts"));
}
}
function loadSessionFromStorage(): StoredSession | null {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const result = parseStoredSession(stored);
return isOk(result) ? result.value : null;
}
function loadSavedAccountsFromStorage(): readonly SavedAccount[] {
const stored = localStorage.getItem(ACCOUNTS_KEY);
if (!stored) return [];
const result = parseStoredAccounts(stored);
return isOk(result) ? result.value : [];
}
function persistSession(session: Session | null): void {
if (session) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} else {
@@ -55,82 +212,85 @@ function saveSession(session: Session | null) {
}
}
function loadSession(): Session | null {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return null;
}
function loadSavedAccounts(): SavedAccount[] {
const stored = localStorage.getItem(ACCOUNTS_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return [];
}
}
return [];
}
function saveSavedAccounts(accounts: SavedAccount[]) {
function persistSavedAccounts(accounts: readonly SavedAccount[]): void {
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
}
function addOrUpdateSavedAccount(session: Session) {
const accounts = loadSavedAccounts();
const existing = accounts.findIndex((a) => a.did === session.did);
const savedAccount: SavedAccount = {
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
if (existing >= 0) {
accounts[existing] = savedAccount;
} else {
accounts.push(savedAccount);
}
saveSavedAccounts(accounts);
state.savedAccounts = accounts;
function updateSavedAccounts(
accounts: readonly SavedAccount[],
session: Session,
): readonly SavedAccount[] {
const newAccount = sessionToSavedAccount(session);
const filtered = accounts.filter((a) => a.did !== newAccount.did);
return [...filtered, newAccount];
}
function removeSavedAccount(did: string) {
const accounts = loadSavedAccounts().filter((a) => a.did !== did);
saveSavedAccounts(accounts);
state.savedAccounts = accounts;
function removeSavedAccountByDid(
accounts: readonly SavedAccount[],
did: Did,
): readonly SavedAccount[] {
return accounts.filter((a) => a.did !== did);
}
function findSavedAccount(
accounts: readonly SavedAccount[],
did: Did,
): SavedAccount | undefined {
return accounts.find((a) => a.did === did);
}
function getSavedAccounts(): readonly SavedAccount[] {
return state.current.savedAccounts;
}
function setState(newState: AuthState): void {
state.current = newState;
}
function setAuthenticated(session: Session): void {
const accounts = updateSavedAccounts(getSavedAccounts(), session);
persistSession(session);
persistSavedAccounts(accounts);
setState(createAuthenticated(session, accounts));
}
function setUnauthenticated(): void {
persistSession(null);
setState(createUnauthenticated(getSavedAccounts()));
}
function setError(error: AuthError): void {
setState(createError(error, getSavedAccounts()));
}
function setLoading(previousSession: Session | null = null): void {
setState(createLoading(getSavedAccounts(), previousSession));
}
async function tryRefreshToken(): Promise<string | null> {
if (!state.session) return null;
if (state.current.kind !== "authenticated") return null;
const currentSession = state.current.session;
try {
const tokens = await refreshOAuthToken(state.session.refreshJwt);
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
const sessionInfo = await api.getSession(tokens.access_token);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
setAuthenticated(session);
return session.accessJwt;
} catch {
return null;
}
}
import { setTokenRefreshCallback } from "./api";
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
setTokenRefreshCallback(tryRefreshToken);
state.loading = true;
state.error = null;
state.savedAccounts = loadSavedAccounts();
const savedAccounts = loadSavedAccountsFromStorage();
setState(createLoading(savedAccounts));
const oauthCallback = checkForOAuthCallback();
if (oauthCallback) {
@@ -146,29 +306,25 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || "",
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
state.loading = false;
return { oauthLoginCompleted: true };
} catch (e) {
state.error = e instanceof Error ? e.message : "OAuth login failed";
state.loading = false;
setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" });
return { oauthLoginCompleted: false };
}
}
const stored = loadSession();
const stored = loadSessionFromStorage();
if (stored) {
try {
const sessionInfo = await api.getSession(stored.accessJwt);
state.session = {
const session: Session = {
...sessionInfo,
accessJwt: stored.accessJwt,
refreshJwt: stored.refreshJwt,
};
addOrUpdateSavedAccount(state.session);
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
@@ -180,85 +336,72 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || stored.refreshJwt,
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
} catch (refreshError) {
console.error("Token refresh failed during init:", refreshError);
saveSession(null);
state.session = null;
setUnauthenticated();
}
} else {
console.error("Non-401 error during getSession:", e);
saveSession(null);
state.session = null;
setUnauthenticated();
}
}
} else {
setState(createUnauthenticated(savedAccounts));
}
state.loading = false;
return { oauthLoginCompleted: false };
}
export async function login(
identifier: string,
password: string,
): Promise<void> {
state.loading = true;
state.error = null;
try {
const session = await api.createSession(identifier, password);
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
} catch (e) {
if (e instanceof ApiError) {
state.error = e.message;
} else {
state.error = "Login failed";
}
throw e;
} finally {
state.loading = false;
): Promise<Result<Session, AuthError>> {
const currentState = state.current;
const previousSession =
currentState.kind === "authenticated" ? currentState.session : null;
setLoading(previousSession);
const result = await typedApi.createSession(identifier, password);
if (isErr(result)) {
const error = toAuthError(result.error);
setError(error);
return err(error);
}
setAuthenticated(result.value);
return ok(result.value);
}
export async function loginWithOAuth(): Promise<void> {
state.loading = true;
state.error = null;
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
setLoading();
try {
await startOAuthLogin();
return ok(undefined);
} catch (e) {
state.loading = false;
state.error = e instanceof Error
? e.message
: "Failed to start OAuth login";
throw e;
const error = toAuthError(e);
setError(error);
return err(error);
}
}
export async function register(
params: CreateAccountParams,
): Promise<CreateAccountResult> {
): Promise<Result<CreateAccountResult, AuthError>> {
try {
const result = await api.createAccount(params);
return result;
return ok(result);
} catch (e) {
if (e instanceof ApiError) {
state.error = e.message;
} else {
state.error = "Registration failed";
}
throw e;
return err(toAuthError(e));
}
}
export async function confirmSignup(
did: string,
verificationCode: string,
): Promise<void> {
state.loading = true;
state.error = null;
): Promise<Result<Session, AuthError>> {
setLoading();
try {
const result = await api.confirmSignup(did, verificationCode);
const session: Session = {
@@ -271,160 +414,170 @@ export async function confirmSignup(
preferredChannel: result.preferredChannel,
preferredChannelVerified: result.preferredChannelVerified,
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
setAuthenticated(session);
return ok(session);
} catch (e) {
if (e instanceof ApiError) {
state.error = e.message;
} else {
state.error = "Verification failed";
}
throw e;
} finally {
state.loading = false;
const error = toAuthError(e);
setError(error);
return err(error);
}
}
export async function resendVerification(did: string): Promise<void> {
export async function resendVerification(
did: string,
): Promise<Result<void, AuthError>> {
try {
await api.resendVerification(did);
return ok(undefined);
} catch (e) {
if (e instanceof ApiError) {
throw e;
}
throw new Error("Failed to resend verification code");
return err(toAuthError(e));
}
}
export function setSession(
session: {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
},
): void {
export function setSession(session: {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
}): void {
const newSession: Session = {
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
state.session = newSession;
saveSession(newSession);
addOrUpdateSavedAccount(newSession);
setAuthenticated(newSession);
}
export async function logout(): Promise<void> {
if (state.session) {
const did = state.session.did;
const refreshToken = state.session.refreshJwt;
export async function logout(): Promise<Result<void, AuthError>> {
if (state.current.kind === "authenticated") {
const { session } = state.current;
const did = unsafeAsDid(session.did);
try {
await fetch("/oauth/revoke", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken }),
body: new URLSearchParams({ token: session.refreshJwt }),
});
} catch {
// Ignore errors on logout
// Ignore revocation errors
}
removeSavedAccount(did);
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
persistSavedAccounts(accounts);
persistSession(null);
setState(createUnauthenticated(accounts));
} else {
setUnauthenticated();
}
state.session = null;
saveSession(null);
return ok(undefined);
}
export async function switchAccount(did: string): Promise<void> {
const account = state.savedAccounts.find((a) => a.did === did);
export async function switchAccount(
did: Did,
): Promise<Result<Session, AuthError>> {
const account = findSavedAccount(getSavedAccounts(), did);
if (!account) {
throw new Error("Account not found");
return err({ type: "validation", message: "Account not found" });
}
state.loading = true;
state.error = null;
setLoading();
try {
const session = await api.getSession(account.accessJwt);
state.session = {
...session,
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
const sessionInfo = await api.getSession(account.accessJwt as string);
const session: Session = {
...sessionInfo,
accessJwt: account.accessJwt as string,
refreshJwt: account.refreshJwt as string,
};
saveSession(state.session);
addOrUpdateSavedAccount(state.session);
setAuthenticated(session);
return ok(session);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
try {
const tokens = await refreshOAuthToken(account.refreshJwt);
const tokens = await refreshOAuthToken(account.refreshJwt as string);
const sessionInfo = await api.getSession(tokens.access_token);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || account.refreshJwt,
refreshJwt: tokens.refresh_token || (account.refreshJwt as string),
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
setAuthenticated(session);
return ok(session);
} catch {
removeSavedAccount(did);
state.error = "Session expired. Please log in again.";
throw new Error("Session expired");
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
persistSavedAccounts(accounts);
const error: AuthError = {
type: "unauthorized",
message: "Session expired. Please log in again.",
};
setState(createError(error, accounts));
return err(error);
}
} else {
state.error = "Failed to switch account";
throw e;
}
} finally {
state.loading = false;
const error = toAuthError(e);
setError(error);
return err(error);
}
}
export function forgetAccount(did: string): void {
removeSavedAccount(did);
export function forgetAccount(did: Did): void {
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
persistSavedAccounts(accounts);
setState({
...state.current,
savedAccounts: accounts,
} as AuthState);
}
export function getAuthState() {
return state;
export function getAuthState(): AuthState {
return state.current;
}
export async function refreshSession(): Promise<void> {
if (!state.session) return;
export async function refreshSession(): Promise<Result<Session, AuthError>> {
if (state.current.kind !== "authenticated") {
return err({ type: "unauthorized", message: "Not authenticated" });
}
const currentSession = state.current.session;
try {
const sessionInfo = await api.getSession(state.session.accessJwt);
state.session = {
const sessionInfo = await api.getSession(currentSession.accessJwt);
const session: Session = {
...sessionInfo,
accessJwt: state.session.accessJwt,
refreshJwt: state.session.refreshJwt,
accessJwt: currentSession.accessJwt,
refreshJwt: currentSession.refreshJwt,
};
saveSession(state.session);
addOrUpdateSavedAccount(state.session);
setAuthenticated(session);
return ok(session);
} catch (e) {
console.error("Failed to refresh session:", e);
return err(toAuthError(e));
}
}
export function getToken(): string | null {
return state.session?.accessJwt ?? null;
export function getToken(): AccessToken | null {
if (state.current.kind === "authenticated") {
return unsafeAsAccessToken(state.current.session.accessJwt);
}
return null;
}
export async function getValidToken(): Promise<string | null> {
if (!state.session) return null;
export async function getValidToken(): Promise<AccessToken | null> {
if (state.current.kind !== "authenticated") return null;
const currentSession = state.current.session;
try {
await api.getSession(state.session.accessJwt);
return state.session.accessJwt;
await api.getSession(currentSession.accessJwt);
return unsafeAsAccessToken(currentSession.accessJwt);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
try {
const tokens = await refreshOAuthToken(state.session.refreshJwt);
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
const sessionInfo = await api.getSession(tokens.access_token);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
};
state.session = session;
saveSession(session);
addOrUpdateSavedAccount(session);
return session.accessJwt;
setAuthenticated(session);
return unsafeAsAccessToken(session.accessJwt);
} catch {
return null;
}
@@ -434,32 +587,68 @@ export async function getValidToken(): Promise<string | null> {
}
export function isAuthenticated(): boolean {
return state.session !== null;
return state.current.kind === "authenticated";
}
export function _testSetState(
newState: {
session: Session | null;
loading: boolean;
error: string | null;
savedAccounts?: SavedAccount[];
},
) {
state.session = newState.session;
state.loading = newState.loading;
state.error = newState.error;
state.savedAccounts = newState.savedAccounts ?? [];
export function isLoading(): boolean {
return state.current.kind === "loading";
}
export function _testResetState() {
state.session = null;
state.loading = true;
state.error = null;
state.savedAccounts = [];
export function getError(): AuthError | null {
return state.current.kind === "error" ? state.current.error : null;
}
export function _testReset() {
export function getSession(): Session | null {
return state.current.kind === "authenticated" ? state.current.session : null;
}
export function matchAuthState<T>(handlers: {
unauthenticated: (accounts: readonly SavedAccount[]) => T;
loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T;
authenticated: (session: Session, accounts: readonly SavedAccount[]) => T;
error: (error: AuthError, accounts: readonly SavedAccount[]) => T;
}): T {
const current = state.current;
switch (current.kind) {
case "unauthenticated":
return handlers.unauthenticated(current.savedAccounts);
case "loading":
return handlers.loading(current.savedAccounts, current.previousSession);
case "authenticated":
return handlers.authenticated(current.session, current.savedAccounts);
case "error":
return handlers.error(current.error, current.savedAccounts);
default:
return assertNever(current);
}
}
export function _testSetState(newState: {
session: Session | null;
loading: boolean;
error: string | null;
savedAccounts?: SavedAccount[];
}): void {
const accounts = newState.savedAccounts ?? [];
if (newState.loading) {
setState(createLoading(accounts, newState.session));
} else if (newState.error) {
setState(createError({ type: "unknown", message: newState.error }, accounts));
} else if (newState.session) {
setState(createAuthenticated(newState.session, accounts));
} else {
setState(createUnauthenticated(accounts));
}
}
export function _testResetState(): void {
setState(createLoading([]));
}
export function _testReset(): void {
_testResetState();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACCOUNTS_KEY);
}
export { type Session };
+1 -4
View File
@@ -35,10 +35,7 @@ function base64UrlEncode(data: Uint8Array | string): string {
const bytes = typeof data === "string"
? new TextEncoder().encode(data)
: data;
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
+8 -16
View File
@@ -600,10 +600,7 @@ export async function generatePKCE(): Promise<{
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/,
"",
@@ -614,11 +611,7 @@ export function base64UrlDecode(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
export function prepareWebAuthnCreationOptions(
@@ -865,13 +858,12 @@ export async function resolvePdsUrl(
);
if (dnsRes.ok) {
const dnsData = await dnsRes.json();
const txtRecords = dnsData.Answer ?? [];
for (const record of txtRecords) {
const txt = record.data?.replace(/"/g, "") ?? "";
if (txt.startsWith("did=")) {
did = txt.slice(4);
break;
}
const txtRecords: Array<{ data?: string }> = dnsData.Answer ?? [];
const didRecord = txtRecords
.map((record) => record.data?.replace(/"/g, "") ?? "")
.find((txt) => txt.startsWith("did="));
if (didRecord) {
did = didRecord.slice(4);
}
}
+1 -3
View File
@@ -36,9 +36,7 @@ export async function migrateBlobs(
"blobs, cursor:",
nextCursor,
);
for (const blob of blobs) {
missingBlobs.push(blob.cid);
}
missingBlobs.push(...blobs.map((blob) => blob.cid));
cursor = nextCursor;
} while (cursor);
+1 -4
View File
@@ -34,10 +34,7 @@ function sha256(plain: string): Promise<ArrayBuffer> {
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/,
"",
@@ -1,5 +1,6 @@
<script lang="ts">
import { api, ApiError } from '../api'
import { resendVerification } from '../auth.svelte'
import type { RegistrationFlow } from './flow.svelte'
interface Props {
@@ -36,7 +37,6 @@
flow.clearError()
try {
const { resendVerification } = await import('../auth.svelte')
await resendVerification(flow.account.did)
resendMessage = 'Verification code resent!'
} catch (err) {
+1 -1
View File
@@ -1,4 +1,5 @@
import { api, ApiError } from "../api";
import { setSession } from "../auth.svelte";
import {
createServiceJwt,
generateDidDocument,
@@ -341,7 +342,6 @@ export function createRegistrationFlow(
async function finalizeSession() {
if (!state.session || !state.account) return;
const { setSession } = await import("../auth.svelte");
setSession({
did: state.account.did,
handle: state.account.handle,
+115 -11
View File
@@ -1,34 +1,138 @@
import {
routes,
type Route,
type RouteParams,
type RoutesWithParams,
buildUrl,
parseRouteParams,
isValidRoute,
} from "./types/routes";
const APP_BASE = "/app";
function getAppPath(): string {
type Brand<T, B extends string> = T & { readonly __brand: B };
type AppPath = Brand<string, "AppPath">;
function asAppPath(path: string): AppPath {
const normalized = path.startsWith("/") ? path : "/" + path;
return normalized as AppPath;
}
function getAppPath(): AppPath {
const pathname = globalThis.location.pathname;
if (pathname.startsWith(APP_BASE)) {
const path = pathname.slice(APP_BASE.length) || "/";
return path.startsWith("/") ? path : "/" + path;
return asAppPath(path);
}
return "/";
return asAppPath("/");
}
let currentPath = $state(getAppPath());
function getSearchParams(): URLSearchParams {
return new URLSearchParams(globalThis.location.search);
}
globalThis.addEventListener("popstate", () => {
currentPath = getAppPath();
interface RouterState {
readonly path: AppPath;
readonly searchParams: URLSearchParams;
}
const state = $state<{ current: RouterState }>({
current: {
path: getAppPath(),
searchParams: getSearchParams(),
},
});
export function navigate(path: string, replace = false) {
const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path);
function updateState(): void {
state.current = {
path: getAppPath(),
searchParams: getSearchParams(),
};
}
globalThis.addEventListener("popstate", updateState);
export function navigate<R extends Route>(
route: R,
options?: {
params?: R extends RoutesWithParams ? RouteParams[R] : never;
replace?: boolean;
},
): void {
const url = options?.params ? buildUrl(route, options.params) : route;
const fullPath = APP_BASE + (url.startsWith("/") ? url : "/" + url);
if (options?.replace) {
globalThis.history.replaceState(null, "", fullPath);
} else {
globalThis.history.pushState(null, "", fullPath);
}
updateState();
}
export function navigateTo(path: string, replace = false): void {
const normalizedPath = path.startsWith("/") ? path : "/" + path;
const fullPath = APP_BASE + normalizedPath;
if (replace) {
globalThis.history.replaceState(null, "", fullPath);
} else {
globalThis.history.pushState(null, "", fullPath);
}
currentPath = path.startsWith("/") ? path : "/" + path;
updateState();
}
export function getCurrentPath() {
return currentPath;
export function getCurrentPath(): AppPath {
return state.current.path;
}
export function getCurrentSearchParams(): URLSearchParams {
return state.current.searchParams;
}
export function getSearchParam(key: string): string | null {
return state.current.searchParams.get(key);
}
export function getFullUrl(path: string): string {
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
}
export function matchRoute(path: AppPath): Route | null {
const pathWithoutQuery = path.split("?")[0];
if (isValidRoute(pathWithoutQuery)) {
return pathWithoutQuery;
}
return null;
}
export function isCurrentRoute(route: Route): boolean {
const pathWithoutQuery = state.current.path.split("?")[0];
return pathWithoutQuery === route;
}
export function getRouteParams<R extends RoutesWithParams>(
_route: R,
): RouteParams[R] {
return parseRouteParams(_route);
}
export type RouteMatch =
| { readonly matched: true; readonly route: Route; readonly params: URLSearchParams }
| { readonly matched: false };
export function match(): RouteMatch {
const route = matchRoute(state.current.path);
if (route) {
return {
matched: true,
route,
params: state.current.searchParams,
};
}
return { matched: false };
}
export { routes, type Route, type RouteParams, type RoutesWithParams };
+74
View File
@@ -0,0 +1,74 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: number
type: ToastType
message: string
duration: number
dismissing?: boolean
}
let nextId = 0
let toasts = $state<Toast[]>([])
export function getToasts(): readonly Toast[] {
return toasts
}
export function showToast(
type: ToastType,
message: string,
duration = 5000
): number {
const id = nextId++
toasts = [...toasts, { id, type, message, duration }]
if (duration > 0) {
setTimeout(() => {
dismissToast(id)
}, duration)
}
return id
}
export function dismissToast(id: number): void {
const toast = toasts.find(t => t.id === id)
if (!toast || toast.dismissing) return
toasts = toasts.map(t => t.id === id ? { ...t, dismissing: true } : t)
setTimeout(() => {
toasts = toasts.filter(t => t.id !== id)
}, 150)
}
export function clearAllToasts(): void {
toasts = []
}
export function success(message: string, duration?: number): number {
return showToast('success', message, duration)
}
export function error(message: string, duration?: number): number {
return showToast('error', message, duration)
}
export function warning(message: string, duration?: number): number {
return showToast('warning', message, duration)
}
export function info(message: string, duration?: number): number {
return showToast('info', message, duration)
}
export const toast = {
show: showToast,
success,
error,
warning,
info,
dismiss: dismissToast,
clear: clearAllToasts,
}
+486
View File
@@ -0,0 +1,486 @@
import type {
Did,
Handle,
AccessToken,
RefreshToken,
Cid,
Rkey,
AtUri,
Nsid,
ISODateString,
EmailAddress,
InviteCode as InviteCodeBrand,
PublicKeyMultibase,
} from './branded'
export type ApiErrorCode =
| 'InvalidRequest'
| 'AuthenticationRequired'
| 'ExpiredToken'
| 'InvalidToken'
| 'AccountNotFound'
| 'HandleNotAvailable'
| 'InvalidHandle'
| 'InvalidPassword'
| 'RateLimitExceeded'
| 'InternalServerError'
| 'AccountTakedown'
| 'AccountDeactivated'
| 'AccountNotVerified'
| 'RepoNotFound'
| 'RecordNotFound'
| 'BlobNotFound'
| 'InvalidInviteCode'
| 'DuplicateCreate'
| 'Unknown'
export type AccountStatus = 'active' | 'deactivated' | 'migrated' | 'suspended' | 'deleted'
export type SessionType = 'oauth' | 'legacy' | 'app_password'
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
export type DidType = 'plc' | 'web' | 'web-external'
export type ReauthMethod = 'password' | 'totp' | 'passkey'
export interface Session {
did: Did
handle: Handle
email?: EmailAddress
emailConfirmed?: boolean
preferredChannel?: VerificationChannel
preferredChannelVerified?: boolean
isAdmin?: boolean
active?: boolean
status?: AccountStatus
migratedToPds?: string
migratedAt?: ISODateString
accessJwt: AccessToken
refreshJwt: RefreshToken
}
export interface VerificationMethod {
id: string
type: string
controller: string
publicKeyMultibase: PublicKeyMultibase
}
export interface ServiceEndpoint {
id: string
type: string
serviceEndpoint: string
}
export interface DidDocument {
'@context': string[]
id: Did
alsoKnownAs: string[]
verificationMethod: VerificationMethod[]
service: ServiceEndpoint[]
}
export interface AppPassword {
name: string
createdAt: ISODateString
scopes?: string
createdByController?: string
}
export interface CreatedAppPassword {
name: string
password: string
createdAt: ISODateString
scopes?: string
}
export interface InviteCodeUse {
usedBy: Did
usedByHandle?: Handle
usedAt: ISODateString
}
export interface InviteCodeInfo {
code: InviteCodeBrand
available: number
disabled: boolean
forAccount: Did
createdBy: Did
createdAt: ISODateString
uses: InviteCodeUse[]
}
export interface CreateAccountParams {
handle: string
email: string
password: string
inviteCode?: string
didType?: DidType
did?: string
signingKey?: string
verificationChannel?: VerificationChannel
discordId?: string
telegramUsername?: string
signalNumber?: string
}
export interface CreateAccountResult {
handle: Handle
did: Did
verificationRequired: boolean
verificationChannel: VerificationChannel
}
export interface ConfirmSignupResult {
accessJwt: AccessToken
refreshJwt: RefreshToken
handle: Handle
did: Did
email?: EmailAddress
emailConfirmed?: boolean
preferredChannel?: VerificationChannel
preferredChannelVerified?: boolean
}
export interface ListAppPasswordsResponse {
passwords: AppPassword[]
}
export interface AccountInviteCodesResponse {
codes: InviteCodeInfo[]
}
export interface CreateInviteCodeResponse {
code: InviteCodeBrand
}
export interface ServerLinks {
privacyPolicy?: string
termsOfService?: string
}
export interface ServerDescription {
availableUserDomains: string[]
inviteCodeRequired: boolean
links?: ServerLinks
version?: string
availableCommsChannels?: VerificationChannel[]
selfHostedDidWebEnabled?: boolean
}
export interface RepoInfo {
did: Did
head: Cid
rev: string
}
export interface ListReposResponse {
repos: RepoInfo[]
cursor?: string
}
export interface NotificationPrefs {
preferredChannel: VerificationChannel
email: EmailAddress
discordId: string | null
discordVerified: boolean
telegramUsername: string | null
telegramVerified: boolean
signalNumber: string | null
signalVerified: boolean
}
export interface NotificationHistoryItem {
createdAt: ISODateString
channel: VerificationChannel
notificationType: string
status: string
subject: string | null
body: string
}
export interface NotificationHistoryResponse {
notifications: NotificationHistoryItem[]
}
export interface ServerStats {
userCount: number
repoCount: number
recordCount: number
blobStorageBytes: number
}
export interface ServerConfig {
serverName: string
primaryColor: string | null
primaryColorDark: string | null
secondaryColor: string | null
secondaryColorDark: string | null
logoCid: Cid | null
}
export interface BlobRef {
$type: 'blob'
ref: { $link: Cid }
mimeType: string
size: number
}
export interface UploadBlobResponse {
blob: BlobRef
}
export interface SessionInfo {
id: string
sessionType: SessionType
clientName: string | null
createdAt: ISODateString
expiresAt: ISODateString
isCurrent: boolean
}
export interface ListSessionsResponse {
sessions: SessionInfo[]
}
export interface RevokeAllSessionsResponse {
revokedCount: number
}
export interface AccountSearchResult {
did: Did
handle: Handle
email?: EmailAddress
indexedAt: ISODateString
emailConfirmedAt?: ISODateString
deactivatedAt?: ISODateString
}
export interface SearchAccountsResponse {
cursor?: string
accounts: AccountSearchResult[]
}
export interface AdminInviteCodeUse {
usedBy: Did
usedAt: ISODateString
}
export interface AdminInviteCode {
code: InviteCodeBrand
available: number
disabled: boolean
forAccount: Did
createdBy: Did
createdAt: ISODateString
uses: AdminInviteCodeUse[]
}
export interface GetInviteCodesResponse {
cursor?: string
codes: AdminInviteCode[]
}
export interface AccountInfo {
did: Did
handle: Handle
email?: EmailAddress
indexedAt: ISODateString
emailConfirmedAt?: ISODateString
invitesDisabled?: boolean
deactivatedAt?: ISODateString
}
export interface RepoDescription {
handle: Handle
did: Did
didDoc: DidDocument
collections: Nsid[]
handleIsCorrect: boolean
}
export interface RecordInfo {
uri: AtUri
cid: Cid
value: unknown
}
export interface ListRecordsResponse {
records: RecordInfo[]
cursor?: string
}
export interface RecordResponse {
uri: AtUri
cid: Cid
value: unknown
}
export interface CreateRecordResponse {
uri: AtUri
cid: Cid
}
export interface TotpStatus {
enabled: boolean
hasBackupCodes: boolean
}
export interface TotpSecret {
uri: string
qrBase64: string
}
export interface EnableTotpResponse {
success: boolean
backupCodes: string[]
}
export interface RegenerateBackupCodesResponse {
backupCodes: string[]
}
export interface PasskeyInfo {
id: string
credentialId: string
friendlyName: string | null
createdAt: ISODateString
lastUsed: ISODateString | null
}
export interface ListPasskeysResponse {
passkeys: PasskeyInfo[]
}
export interface StartPasskeyRegistrationResponse {
options: PublicKeyCredentialCreationOptions
}
export interface FinishPasskeyRegistrationResponse {
id: string
credentialId: string
}
export interface TrustedDevice {
id: string
userAgent: string | null
friendlyName: string | null
trustedAt: ISODateString | null
trustedUntil: ISODateString | null
lastSeenAt: ISODateString
}
export interface ListTrustedDevicesResponse {
devices: TrustedDevice[]
}
export interface ReauthStatus {
requiresReauth: boolean
lastReauthAt: ISODateString | null
availableMethods: ReauthMethod[]
}
export interface ReauthResponse {
success: boolean
reauthAt: ISODateString
}
export interface ReauthPasskeyStartResponse {
options: PublicKeyCredentialRequestOptions
}
export interface ReserveSigningKeyResponse {
signingKey: PublicKeyMultibase
}
export interface RecommendedDidCredentials {
rotationKeys?: PublicKeyMultibase[]
alsoKnownAs?: string[]
verificationMethods?: { atproto?: PublicKeyMultibase }
services?: { atproto_pds?: { type: string; endpoint: string } }
}
export interface PasskeyAccountCreateResponse {
did: Did
handle: Handle
setupToken: string
setupExpiresAt: ISODateString
}
export interface CompletePasskeySetupResponse {
did: Did
handle: Handle
appPassword: string
appPasswordName: string
}
export interface VerifyTokenResponse {
success: boolean
did: Did
purpose: string
channel: VerificationChannel
}
export interface BackupInfo {
id: string
repoRev: string
repoRootCid: Cid
blockCount: number
sizeBytes: number
createdAt: ISODateString
}
export interface ListBackupsResponse {
backups: BackupInfo[]
backupEnabled: boolean
}
export interface CreateBackupResponse {
id: string
repoRev: string
sizeBytes: number
blockCount: number
}
export interface SetBackupEnabledResponse {
enabled: boolean
}
export interface EmailUpdateResponse {
tokenRequired: boolean
}
export interface LegacyLoginPreference {
allowLegacyLogin: boolean
hasMfa: boolean
}
export interface UpdateLegacyLoginResponse {
allowLegacyLogin: boolean
}
export interface UpdateLocaleResponse {
preferredLocale: string
}
export interface PasswordStatus {
hasPassword: boolean
}
export interface SuccessResponse {
success: boolean
}
export interface CheckEmailVerifiedResponse {
verified: boolean
}
export interface VerifyMigrationEmailResponse {
success: boolean
did: Did
}
export interface ResendMigrationVerificationResponse {
sent: boolean
}
+188
View File
@@ -0,0 +1,188 @@
declare const __brand: unique symbol
type Brand<T, B extends string> = T & { readonly [__brand]: B }
export type Did = Brand<string, 'Did'>
export type DidPlc = Brand<Did, 'DidPlc'>
export type DidWeb = Brand<Did, 'DidWeb'>
export type Handle = Brand<string, 'Handle'>
export type AccessToken = Brand<string, 'AccessToken'>
export type RefreshToken = Brand<string, 'RefreshToken'>
export type ServiceToken = Brand<string, 'ServiceToken'>
export type SetupToken = Brand<string, 'SetupToken'>
export type Cid = Brand<string, 'Cid'>
export type Rkey = Brand<string, 'Rkey'>
export type AtUri = Brand<string, 'AtUri'>
export type Nsid = Brand<string, 'Nsid'>
export type ISODateString = Brand<string, 'ISODateString'>
export type EmailAddress = Brand<string, 'EmailAddress'>
export type InviteCode = Brand<string, 'InviteCode'>
export type PublicKeyMultibase = Brand<string, 'PublicKeyMultibase'>
export type DidKeyString = Brand<string, 'DidKeyString'>
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/
const DID_WEB_REGEX = /^did:web:.+$/
const HANDLE_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/
const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/
const NSID_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
export function isDid(s: string): s is Did {
return s.startsWith('did:plc:') || s.startsWith('did:web:')
}
export function isDidPlc(s: string): s is DidPlc {
return DID_PLC_REGEX.test(s)
}
export function isDidWeb(s: string): s is DidWeb {
return DID_WEB_REGEX.test(s)
}
export function isHandle(s: string): s is Handle {
return HANDLE_REGEX.test(s) && s.length <= 253
}
export function isAtUri(s: string): s is AtUri {
return AT_URI_REGEX.test(s)
}
export function isCid(s: string): s is Cid {
return CID_REGEX.test(s)
}
export function isNsid(s: string): s is Nsid {
return NSID_REGEX.test(s)
}
export function isEmail(s: string): s is EmailAddress {
return EMAIL_REGEX.test(s)
}
export function isISODate(s: string): s is ISODateString {
return ISO_DATE_REGEX.test(s)
}
export function asDid(s: string): Did {
if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`)
return s
}
export function asDidPlc(s: string): DidPlc {
if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`)
return s as DidPlc
}
export function asDidWeb(s: string): DidWeb {
if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`)
return s as DidWeb
}
export function asHandle(s: string): Handle {
if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`)
return s
}
export function asAtUri(s: string): AtUri {
if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`)
return s
}
export function asCid(s: string): Cid {
if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`)
return s
}
export function asNsid(s: string): Nsid {
if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`)
return s
}
export function asEmail(s: string): EmailAddress {
if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`)
return s
}
export function asISODate(s: string): ISODateString {
if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`)
return s
}
export function unsafeAsDid(s: string): Did {
return s as Did
}
export function unsafeAsHandle(s: string): Handle {
return s as Handle
}
export function unsafeAsAccessToken(s: string): AccessToken {
return s as AccessToken
}
export function unsafeAsRefreshToken(s: string): RefreshToken {
return s as RefreshToken
}
export function unsafeAsServiceToken(s: string): ServiceToken {
return s as ServiceToken
}
export function unsafeAsSetupToken(s: string): SetupToken {
return s as SetupToken
}
export function unsafeAsCid(s: string): Cid {
return s as Cid
}
export function unsafeAsRkey(s: string): Rkey {
return s as Rkey
}
export function unsafeAsAtUri(s: string): AtUri {
return s as AtUri
}
export function unsafeAsNsid(s: string): Nsid {
return s as Nsid
}
export function unsafeAsISODate(s: string): ISODateString {
return s as ISODateString
}
export function unsafeAsEmail(s: string): EmailAddress {
return s as EmailAddress
}
export function unsafeAsInviteCode(s: string): InviteCode {
return s as InviteCode
}
export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase {
return s as PublicKeyMultibase
}
export function unsafeAsDidKey(s: string): DidKeyString {
return s as DidKeyString
}
export function parseAtUri(uri: AtUri): { repo: Did; collection: Nsid; rkey: Rkey } {
const parts = uri.replace('at://', '').split('/')
return {
repo: unsafeAsDid(parts[0]),
collection: unsafeAsNsid(parts[1]),
rkey: unsafeAsRkey(parts[2]),
}
}
export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri {
return `at://${repo}/${collection}/${rkey}` as AtUri
}
+49
View File
@@ -0,0 +1,49 @@
export function assertNever(x: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`)
}
export function exhaustive<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => void>
): void {
const handler = handlers[value]
if (handler) {
handler()
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`)
}
}
export function exhaustiveMap<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => R>
): R {
const handler = handlers[value]
if (handler) {
return handler()
}
return assertNever(value as never, `Unhandled case: ${String(value)}`)
}
export async function exhaustiveAsync<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => Promise<void>>
): Promise<void> {
const handler = handlers[value]
if (handler) {
await handler()
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`)
}
}
export async function exhaustiveMapAsync<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => Promise<R>>
): Promise<R> {
const handler = handlers[value]
if (handler) {
return handler()
}
return assertNever(value as never, `Unhandled case: ${String(value)}`)
}
+5
View File
@@ -0,0 +1,5 @@
export * from './result'
export * from './branded'
export * from './exhaustive'
export * from './api'
export * from './routes'
+94
View File
@@ -0,0 +1,94 @@
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value }
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error }
}
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok
}
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
return !result.ok
}
export function map<T, U, E>(result: Result<T, E>, fn: (t: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result
}
export function mapErr<T, E, F>(result: Result<T, E>, fn: (e: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error))
}
export function flatMap<T, U, E>(result: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> {
return result.ok ? fn(result.value) : result
}
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value
throw result.error instanceof Error ? result.error : new Error(String(result.error))
}
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.ok ? result.value : defaultValue
}
export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T {
return result.ok ? result.value : fn(result.error)
}
export function match<T, E, U>(
result: Result<T, E>,
handlers: { ok: (t: T) => U; err: (e: E) => U }
): U {
return result.ok ? handlers.ok(result.value) : handlers.err(result.error)
}
export async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn())
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function tryAsyncWith<T, E>(
fn: () => Promise<T>,
mapError: (e: unknown) => E
): Promise<Result<T, E>> {
try {
return ok(await fn())
} catch (e) {
return err(mapError(e))
}
}
export function fromNullable<T>(value: T | null | undefined): Result<T, null> {
return value != null ? ok(value) : err(null)
}
export function toNullable<T, E>(result: Result<T, E>): T | null {
return result.ok ? result.value : null
}
export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> {
const values: T[] = []
for (const result of results) {
if (!result.ok) return result
values.push(result.value)
}
return ok(values)
}
export async function collectAsync<T, E>(
results: Promise<Result<T, E>>[]
): Promise<Result<T[], E>> {
const settled = await Promise.all(results)
return collect(settled)
}
+83
View File
@@ -0,0 +1,83 @@
export const routes = {
login: '/login',
register: '/register',
registerPasskey: '/register-passkey',
dashboard: '/dashboard',
settings: '/settings',
security: '/security',
sessions: '/sessions',
appPasswords: '/app-passwords',
trustedDevices: '/trusted-devices',
inviteCodes: '/invite-codes',
comms: '/comms',
repo: '/repo',
controllers: '/controllers',
delegationAudit: '/delegation-audit',
actAs: '/act-as',
didDocument: '/did-document',
migrate: '/migrate',
admin: '/admin',
verify: '/verify',
resetPassword: '/reset-password',
recoverPasskey: '/recover-passkey',
requestPasskeyRecovery: '/request-passkey-recovery',
oauthLogin: '/oauth/login',
oauthConsent: '/oauth/consent',
oauthAccounts: '/oauth/accounts',
oauth2fa: '/oauth/2fa',
oauthTotp: '/oauth/totp',
oauthPasskey: '/oauth/passkey',
oauthDelegation: '/oauth/delegation',
oauthError: '/oauth/error',
} as const
export type Route = (typeof routes)[keyof typeof routes]
export type RouteKey = keyof typeof routes
export function isValidRoute(path: string): path is Route {
return Object.values(routes).includes(path as Route)
}
export interface RouteParams {
[routes.verify]: { token?: string; email?: string }
[routes.resetPassword]: { token?: string }
[routes.recoverPasskey]: { token?: string; did?: string }
[routes.oauthLogin]: { request_uri?: string; error?: string }
[routes.oauthConsent]: { request_uri?: string; client_id?: string }
[routes.oauthAccounts]: { request_uri?: string }
[routes.oauth2fa]: { request_uri?: string; channel?: string }
[routes.oauthTotp]: { request_uri?: string }
[routes.oauthPasskey]: { request_uri?: string }
[routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }
[routes.oauthError]: { error?: string; error_description?: string }
[routes.migrate]: { code?: string; state?: string }
}
export type RoutesWithParams = keyof RouteParams
export function buildUrl<R extends Route>(
route: R,
params?: R extends RoutesWithParams ? RouteParams[R] : never
): string {
if (!params) return route
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value != null) {
searchParams.set(key, String(value))
}
}
const queryString = searchParams.toString()
return queryString ? `${route}?${queryString}` : route
}
export function parseRouteParams<R extends RoutesWithParams>(
route: R
): RouteParams[R] {
const params = new URLSearchParams(globalThis.location.search)
const result: Record<string, string> = {}
for (const [key, value] of params.entries()) {
result[key] = value
}
return result as RouteParams[R]
}
+332
View File
@@ -0,0 +1,332 @@
import { z } from 'zod'
import type {
Did,
Handle,
AccessToken,
RefreshToken,
Cid,
Nsid,
AtUri,
Rkey,
ISODateString,
EmailAddress,
InviteCode,
PublicKeyMultibase,
} from './branded'
import {
unsafeAsDid,
unsafeAsHandle,
unsafeAsAccessToken,
unsafeAsRefreshToken,
unsafeAsCid,
unsafeAsNsid,
unsafeAsAtUri,
unsafeAsRkey,
unsafeAsISODate,
unsafeAsEmail,
unsafeAsInviteCode,
unsafeAsPublicKeyMultibase,
} from './branded'
const did = z.string().transform((s) => unsafeAsDid(s))
const handle = z.string().transform((s) => unsafeAsHandle(s))
const accessToken = z.string().transform((s) => unsafeAsAccessToken(s))
const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s))
const cid = z.string().transform((s) => unsafeAsCid(s))
const nsid = z.string().transform((s) => unsafeAsNsid(s))
const atUri = z.string().transform((s) => unsafeAsAtUri(s))
const rkey = z.string().transform((s) => unsafeAsRkey(s))
const isoDate = z.string().transform((s) => unsafeAsISODate(s))
const email = z.string().transform((s) => unsafeAsEmail(s))
const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s))
const publicKeyMultibase = z.string().transform((s) => unsafeAsPublicKeyMultibase(s))
export const verificationChannel = z.enum(['email', 'discord', 'telegram', 'signal'])
export const didType = z.enum(['plc', 'web', 'web-external'])
export const accountStatus = z.enum(['active', 'deactivated', 'migrated', 'suspended', 'deleted'])
export const sessionType = z.enum(['oauth', 'legacy', 'app_password'])
export const reauthMethod = z.enum(['password', 'totp', 'passkey'])
export const sessionSchema = z.object({
did: did,
handle: handle,
email: email.optional(),
emailConfirmed: z.boolean().optional(),
preferredChannel: verificationChannel.optional(),
preferredChannelVerified: z.boolean().optional(),
isAdmin: z.boolean().optional(),
active: z.boolean().optional(),
status: accountStatus.optional(),
migratedToPds: z.string().optional(),
migratedAt: isoDate.optional(),
accessJwt: accessToken,
refreshJwt: refreshToken,
})
export const serverLinksSchema = z.object({
privacyPolicy: z.string().optional(),
termsOfService: z.string().optional(),
})
export const serverDescriptionSchema = z.object({
availableUserDomains: z.array(z.string()),
inviteCodeRequired: z.boolean(),
links: serverLinksSchema.optional(),
version: z.string().optional(),
availableCommsChannels: z.array(verificationChannel).optional(),
selfHostedDidWebEnabled: z.boolean().optional(),
})
export const appPasswordSchema = z.object({
name: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
createdByController: z.string().optional(),
})
export const createdAppPasswordSchema = z.object({
name: z.string(),
password: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
})
export const inviteCodeUseSchema = z.object({
usedBy: did,
usedByHandle: handle.optional(),
usedAt: isoDate,
})
export const inviteCodeInfoSchema = z.object({
code: inviteCode,
available: z.number(),
disabled: z.boolean(),
forAccount: did,
createdBy: did,
createdAt: isoDate,
uses: z.array(inviteCodeUseSchema),
})
export const sessionInfoSchema = z.object({
id: z.string(),
sessionType: sessionType,
clientName: z.string().nullable(),
createdAt: isoDate,
expiresAt: isoDate,
isCurrent: z.boolean(),
})
export const listSessionsResponseSchema = z.object({
sessions: z.array(sessionInfoSchema),
})
export const totpStatusSchema = z.object({
enabled: z.boolean(),
hasBackupCodes: z.boolean(),
})
export const totpSecretSchema = z.object({
uri: z.string(),
qrBase64: z.string(),
})
export const enableTotpResponseSchema = z.object({
success: z.boolean(),
backupCodes: z.array(z.string()),
})
export const passkeyInfoSchema = z.object({
id: z.string(),
credentialId: z.string(),
friendlyName: z.string().nullable(),
createdAt: isoDate,
lastUsed: isoDate.nullable(),
})
export const listPasskeysResponseSchema = z.object({
passkeys: z.array(passkeyInfoSchema),
})
export const trustedDeviceSchema = z.object({
id: z.string(),
userAgent: z.string().nullable(),
friendlyName: z.string().nullable(),
trustedAt: isoDate.nullable(),
trustedUntil: isoDate.nullable(),
lastSeenAt: isoDate,
})
export const listTrustedDevicesResponseSchema = z.object({
devices: z.array(trustedDeviceSchema),
})
export const reauthStatusSchema = z.object({
requiresReauth: z.boolean(),
lastReauthAt: isoDate.nullable(),
availableMethods: z.array(reauthMethod),
})
export const reauthResponseSchema = z.object({
success: z.boolean(),
reauthAt: isoDate,
})
export const notificationPrefsSchema = z.object({
preferredChannel: verificationChannel,
email: email,
discordId: z.string().nullable(),
discordVerified: z.boolean(),
telegramUsername: z.string().nullable(),
telegramVerified: z.boolean(),
signalNumber: z.string().nullable(),
signalVerified: z.boolean(),
})
export const verificationMethodSchema = z.object({
id: z.string(),
type: z.string(),
controller: z.string(),
publicKeyMultibase: publicKeyMultibase,
})
export const serviceEndpointSchema = z.object({
id: z.string(),
type: z.string(),
serviceEndpoint: z.string(),
})
export const didDocumentSchema = z.object({
'@context': z.array(z.string()),
id: did,
alsoKnownAs: z.array(z.string()),
verificationMethod: z.array(verificationMethodSchema),
service: z.array(serviceEndpointSchema),
})
export const repoDescriptionSchema = z.object({
handle: handle,
did: did,
didDoc: didDocumentSchema,
collections: z.array(nsid),
handleIsCorrect: z.boolean(),
})
export const recordInfoSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
})
export const listRecordsResponseSchema = z.object({
records: z.array(recordInfoSchema),
cursor: z.string().optional(),
})
export const recordResponseSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
})
export const createRecordResponseSchema = z.object({
uri: atUri,
cid: cid,
})
export const serverStatsSchema = z.object({
userCount: z.number(),
repoCount: z.number(),
recordCount: z.number(),
blobStorageBytes: z.number(),
})
export const serverConfigSchema = z.object({
serverName: z.string(),
primaryColor: z.string().nullable(),
primaryColorDark: z.string().nullable(),
secondaryColor: z.string().nullable(),
secondaryColorDark: z.string().nullable(),
logoCid: cid.nullable(),
})
export const passwordStatusSchema = z.object({
hasPassword: z.boolean(),
})
export const successResponseSchema = z.object({
success: z.boolean(),
})
export const legacyLoginPreferenceSchema = z.object({
allowLegacyLogin: z.boolean(),
hasMfa: z.boolean(),
})
export const accountInfoSchema = z.object({
did: did,
handle: handle,
email: email.optional(),
indexedAt: isoDate,
emailConfirmedAt: isoDate.optional(),
invitesDisabled: z.boolean().optional(),
deactivatedAt: isoDate.optional(),
})
export const searchAccountsResponseSchema = z.object({
cursor: z.string().optional(),
accounts: z.array(accountInfoSchema),
})
export const backupInfoSchema = z.object({
id: z.string(),
repoRev: z.string(),
repoRootCid: cid,
blockCount: z.number(),
sizeBytes: z.number(),
createdAt: isoDate,
})
export const listBackupsResponseSchema = z.object({
backups: z.array(backupInfoSchema),
backupEnabled: z.boolean(),
})
export const createBackupResponseSchema = z.object({
id: z.string(),
repoRev: z.string(),
sizeBytes: z.number(),
blockCount: z.number(),
})
export type ValidatedSession = z.infer<typeof sessionSchema>
export type ValidatedServerDescription = z.infer<typeof serverDescriptionSchema>
export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>
export type ValidatedCreatedAppPassword = z.infer<typeof createdAppPasswordSchema>
export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>
export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>
export type ValidatedListSessionsResponse = z.infer<typeof listSessionsResponseSchema>
export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>
export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>
export type ValidatedEnableTotpResponse = z.infer<typeof enableTotpResponseSchema>
export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>
export type ValidatedListPasskeysResponse = z.infer<typeof listPasskeysResponseSchema>
export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>
export type ValidatedListTrustedDevicesResponse = z.infer<typeof listTrustedDevicesResponseSchema>
export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>
export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>
export type ValidatedNotificationPrefs = z.infer<typeof notificationPrefsSchema>
export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>
export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>
export type ValidatedListRecordsResponse = z.infer<typeof listRecordsResponseSchema>
export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>
export type ValidatedCreateRecordResponse = z.infer<typeof createRecordResponseSchema>
export type ValidatedServerStats = z.infer<typeof serverStatsSchema>
export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>
export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>
export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>
export type ValidatedLegacyLoginPreference = z.infer<typeof legacyLoginPreferenceSchema>
export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>
export type ValidatedSearchAccountsResponse = z.infer<typeof searchAccountsResponseSchema>
export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema>
export type ValidatedListBackupsResponse = z.infer<typeof listBackupsResponseSchema>
export type ValidatedCreateBackupResponse = z.infer<typeof createBackupResponseSchema>
+190
View File
@@ -0,0 +1,190 @@
import type { Option } from './option'
export function first<T>(arr: readonly T[]): Option<T> {
return arr[0] ?? null
}
export function last<T>(arr: readonly T[]): Option<T> {
return arr[arr.length - 1] ?? null
}
export function at<T>(arr: readonly T[], index: number): Option<T> {
if (index < 0) index = arr.length + index
return arr[index] ?? null
}
export function find<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<T> {
return arr.find(predicate) ?? null
}
export function findMap<T, U>(arr: readonly T[], fn: (t: T) => Option<U>): Option<U> {
for (const item of arr) {
const result = fn(item)
if (result != null) return result
}
return null
}
export function findIndex<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<number> {
const index = arr.findIndex(predicate)
return index >= 0 ? index : null
}
export function partition<T>(
arr: readonly T[],
predicate: (t: T) => boolean
): [T[], T[]] {
const pass: T[] = []
const fail: T[] = []
for (const item of arr) {
if (predicate(item)) {
pass.push(item)
} else {
fail.push(item)
}
}
return [pass, fail]
}
export function groupBy<T, K extends string | number>(
arr: readonly T[],
keyFn: (t: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>
for (const item of arr) {
const key = keyFn(item)
if (!result[key]) {
result[key] = []
}
result[key].push(item)
}
return result
}
export function unique<T>(arr: readonly T[]): T[] {
return [...new Set(arr)]
}
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
const seen = new Set<K>()
const result: T[] = []
for (const item of arr) {
const key = keyFn(item)
if (!seen.has(key)) {
seen.add(key)
result.push(item)
}
}
return result
}
export function sortBy<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a)
const kb = keyFn(b)
if (ka < kb) return -1
if (ka > kb) return 1
return 0
})
}
export function sortByDesc<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a)
const kb = keyFn(b)
if (ka > kb) return -1
if (ka < kb) return 1
return 0
})
}
export function chunk<T>(arr: readonly T[], size: number): T[][] {
const result: T[][] = []
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size))
}
return result
}
export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] {
const length = Math.min(a.length, b.length)
const result: [T, U][] = []
for (let i = 0; i < length; i++) {
result.push([a[i], b[i]])
}
return result
}
export function zipWith<T, U, R>(
a: readonly T[],
b: readonly U[],
fn: (t: T, u: U) => R
): R[] {
const length = Math.min(a.length, b.length)
const result: R[] = []
for (let i = 0; i < length; i++) {
result.push(fn(a[i], b[i]))
}
return result
}
export function intersperse<T>(arr: readonly T[], separator: T): T[] {
if (arr.length <= 1) return [...arr]
const result: T[] = [arr[0]]
for (let i = 1; i < arr.length; i++) {
result.push(separator, arr[i])
}
return result
}
export function range(start: number, end: number): number[] {
const result: number[] = []
for (let i = start; i < end; i++) {
result.push(i)
}
return result
}
export function isEmpty<T>(arr: readonly T[]): boolean {
return arr.length === 0
}
export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] {
return arr.length > 0
}
export function sum(arr: readonly number[]): number {
return arr.reduce((acc, n) => acc + n, 0)
}
export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number {
return arr.reduce((acc, t) => acc + fn(t), 0)
}
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null
let max = arr[0]
let maxValue = fn(max)
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i])
if (value > maxValue) {
max = arr[i]
maxValue = value
}
}
return max
}
export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null
let min = arr[0]
let minValue = fn(min)
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i])
if (value < minValue) {
min = arr[i]
minValue = value
}
}
return min
}
+246
View File
@@ -0,0 +1,246 @@
import { ok, err, type Result } from '../types/result'
export function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fn(...args)
timeoutId = null
}, ms)
}) as T & { cancel: () => void }
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
return debounced
}
export function throttle<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number
): T {
let lastCall = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
return ((...args: Parameters<T>) => {
const now = Date.now()
const remaining = ms - (now - lastCall)
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
lastCall = now
fn(...args)
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now()
timeoutId = null
fn(...args)
}, remaining)
}
}) as T
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function retry<T>(
fn: () => Promise<T>,
options: {
attempts?: number
delay?: number
backoff?: number
shouldRetry?: (error: unknown, attempt: number) => boolean
} = {}
): Promise<T> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options
let lastError: unknown
let currentDelay = delay
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === attempts || !shouldRetry(error, attempt)) {
throw error
}
await sleep(currentDelay)
currentDelay *= backoff
}
}
throw lastError
}
export async function retryResult<T, E>(
fn: () => Promise<Result<T, E>>,
options: {
attempts?: number
delay?: number
backoff?: number
shouldRetry?: (error: E, attempt: number) => boolean
} = {}
): Promise<Result<T, E>> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options
let lastResult: Result<T, E> | null = null
let currentDelay = delay
for (let attempt = 1; attempt <= attempts; attempt++) {
const result = await fn()
lastResult = result
if (result.ok) {
return result
}
if (attempt === attempts || !shouldRetry(result.error, attempt)) {
return result
}
await sleep(currentDelay)
currentDelay *= backoff
}
return lastResult!
}
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms`))
}, ms)
promise
.then((value) => {
clearTimeout(timeoutId)
resolve(value)
})
.catch((error) => {
clearTimeout(timeoutId)
reject(error)
})
})
}
export async function timeoutResult<T>(
promise: Promise<Result<T, Error>>,
ms: number
): Promise<Result<T, Error>> {
try {
return await timeout(promise, ms)
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function parallel<T>(
tasks: (() => Promise<T>)[],
concurrency: number
): Promise<T[]> {
const results: T[] = []
const executing: Promise<void>[] = []
for (const task of tasks) {
const p = task().then((result) => {
results.push(result)
})
executing.push(p)
if (executing.length >= concurrency) {
await Promise.race(executing)
executing.splice(
executing.findIndex((e) => e === p),
1
)
}
}
await Promise.all(executing)
return results
}
export async function mapParallel<T, U>(
items: T[],
fn: (item: T, index: number) => Promise<U>,
concurrency: number
): Promise<U[]> {
const results: U[] = new Array(items.length)
const executing: Promise<void>[] = []
for (let i = 0; i < items.length; i++) {
const index = i
const p = fn(items[index], index).then((result) => {
results[index] = result
})
executing.push(p)
if (executing.length >= concurrency) {
await Promise.race(executing)
const doneIndex = executing.findIndex(
(e) =>
(e as Promise<void> & { _done?: boolean })._done !== false
)
if (doneIndex >= 0) {
executing.splice(doneIndex, 1)
}
}
}
await Promise.all(executing)
return results
}
export function createAbortable<T>(
fn: (signal: AbortSignal) => Promise<T>
): { promise: Promise<T>; abort: () => void } {
const controller = new AbortController()
return {
promise: fn(controller.signal),
abort: () => controller.abort(),
}
}
export interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+3
View File
@@ -0,0 +1,3 @@
export * from './option'
export * from './array'
export * from './async'
+79
View File
@@ -0,0 +1,79 @@
export type Option<T> = T | null | undefined
export function isSome<T>(opt: Option<T>): opt is T {
return opt != null
}
export function isNone<T>(opt: Option<T>): opt is null | undefined {
return opt == null
}
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
return isSome(opt) ? fn(opt) : null
}
export function flatMap<T, U>(opt: Option<T>, fn: (t: T) => Option<U>): Option<U> {
return isSome(opt) ? fn(opt) : null
}
export function filter<T>(opt: Option<T>, predicate: (t: T) => boolean): Option<T> {
return isSome(opt) && predicate(opt) ? opt : null
}
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return isSome(opt) ? opt : defaultValue
}
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
return isSome(opt) ? opt : fn()
}
export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T {
if (isSome(opt)) return opt
if (error instanceof Error) throw error
throw new Error(error ?? 'Expected value but got null/undefined')
}
export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
if (isSome(opt)) fn(opt)
return opt
}
export function match<T, U>(
opt: Option<T>,
handlers: { some: (t: T) => U; none: () => U }
): U {
return isSome(opt) ? handlers.some(opt) : handlers.none()
}
export function toArray<T>(opt: Option<T>): T[] {
return isSome(opt) ? [opt] : []
}
export function fromArray<T>(arr: T[]): Option<T> {
return arr.length > 0 ? arr[0] : null
}
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
return isSome(a) && isSome(b) ? [a, b] : null
}
export function zipWith<T, U, R>(
a: Option<T>,
b: Option<U>,
fn: (t: T, u: U) => R
): Option<R> {
return isSome(a) && isSome(b) ? fn(a, b) : null
}
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
return isSome(a) ? a : b
}
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
return isSome(a) ? a : fn()
}
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
return isSome(a) ? b : null
}
+260
View File
@@ -0,0 +1,260 @@
import { ok, err, type Result } from './types/result'
import {
type Did,
type DidPlc,
type DidWeb,
type Handle,
type EmailAddress,
type AtUri,
type Cid,
type Nsid,
type ISODateString,
isDid,
isDidPlc,
isDidWeb,
isHandle,
isEmail,
isAtUri,
isCid,
isNsid,
isISODate,
} from './types/branded'
export class ValidationError extends Error {
constructor(
message: string,
public readonly field?: string,
public readonly value?: unknown
) {
super(message)
this.name = 'ValidationError'
}
}
export function parseDid(s: string): Result<Did, ValidationError> {
if (isDid(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid DID: ${s}`, 'did', s))
}
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
if (isDidPlc(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid DID:PLC: ${s}`, 'did', s))
}
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
if (isDidWeb(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid DID:WEB: ${s}`, 'did', s))
}
export function parseHandle(s: string): Result<Handle, ValidationError> {
const trimmed = s.trim().toLowerCase()
if (isHandle(trimmed)) {
return ok(trimmed)
}
return err(new ValidationError(`Invalid handle: ${s}`, 'handle', s))
}
export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
const trimmed = s.trim().toLowerCase()
if (isEmail(trimmed)) {
return ok(trimmed)
}
return err(new ValidationError(`Invalid email: ${s}`, 'email', s))
}
export function parseAtUri(s: string): Result<AtUri, ValidationError> {
if (isAtUri(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid AT-URI: ${s}`, 'uri', s))
}
export function parseCid(s: string): Result<Cid, ValidationError> {
if (isCid(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid CID: ${s}`, 'cid', s))
}
export function parseNsid(s: string): Result<Nsid, ValidationError> {
if (isNsid(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid NSID: ${s}`, 'nsid', s))
}
export function parseISODate(s: string): Result<ISODateString, ValidationError> {
if (isISODate(s)) {
return ok(s)
}
return err(new ValidationError(`Invalid ISO date: ${s}`, 'date', s))
}
export interface PasswordValidationResult {
valid: boolean
errors: string[]
strength: 'weak' | 'fair' | 'good' | 'strong'
}
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (password.length > 256) {
errors.push('Password must be at most 256 characters')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain a lowercase letter')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter')
}
if (!/\d/.test(password)) {
errors.push('Password must contain a number')
}
let strength: PasswordValidationResult['strength'] = 'weak'
if (errors.length === 0) {
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
const isLong = password.length >= 12
const isVeryLong = password.length >= 16
if (isVeryLong && hasSpecial) {
strength = 'strong'
} else if (isLong || hasSpecial) {
strength = 'good'
} else {
strength = 'fair'
}
}
return {
valid: errors.length === 0,
errors,
strength,
}
}
export function validateHandle(handle: string): Result<Handle, ValidationError> {
const trimmed = handle.trim().toLowerCase()
if (trimmed.length < 3) {
return err(new ValidationError('Handle must be at least 3 characters', 'handle', handle))
}
if (trimmed.length > 253) {
return err(new ValidationError('Handle must be at most 253 characters', 'handle', handle))
}
if (!isHandle(trimmed)) {
return err(new ValidationError('Invalid handle format', 'handle', handle))
}
return ok(trimmed)
}
export function validateInviteCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim()
if (trimmed.length === 0) {
return err(new ValidationError('Invite code is required', 'inviteCode', code))
}
const pattern = /^[a-zA-Z0-9-]+$/
if (!pattern.test(trimmed)) {
return err(new ValidationError('Invalid invite code format', 'inviteCode', code))
}
return ok(trimmed)
}
export function validateTotpCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, '')
if (!/^\d{6}$/.test(trimmed)) {
return err(new ValidationError('TOTP code must be 6 digits', 'code', code))
}
return ok(trimmed)
}
export function validateBackupCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, '').toLowerCase()
if (!/^[a-z0-9]{8}$/.test(trimmed)) {
return err(new ValidationError('Invalid backup code format', 'code', code))
}
return ok(trimmed)
}
export interface FormValidation<T> {
validate: () => Result<T, ValidationError[]>
field: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>
) => FormValidation<T>
optional: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>
) => FormValidation<T>
}
export function createFormValidation<T extends Record<string, unknown>>(
data: Record<string, unknown>
): FormValidation<T> {
const validators: Array<{
key: string
validator: (value: unknown) => Result<unknown, ValidationError>
optional: boolean
}> = []
const builder: FormValidation<T> = {
field: (key, validator) => {
validators.push({ key: key as string, validator, optional: false })
return builder
},
optional: (key, validator) => {
validators.push({ key: key as string, validator, optional: true })
return builder
},
validate: () => {
const errors: ValidationError[] = []
const result: Record<string, unknown> = {}
for (const { key, validator, optional } of validators) {
const value = data[key]
if (value == null || value === '') {
if (!optional) {
errors.push(new ValidationError(`${key} is required`, key))
}
continue
}
const validated = validator(value)
if (validated.ok) {
result[key] = validated.value
} else {
errors.push(validated.error)
}
}
if (errors.length > 0) {
return err(errors)
}
return ok(result as T)
},
}
return builder
}
+156
View File
@@ -0,0 +1,156 @@
export interface PublicKeyCredentialDescriptorJSON {
type: 'public-key'
id: string
transports?: AuthenticatorTransport[]
}
export interface PublicKeyCredentialUserEntityJSON {
id: string
name: string
displayName: string
}
export interface PublicKeyCredentialRpEntityJSON {
name: string
id?: string
}
export interface PublicKeyCredentialParametersJSON {
type: 'public-key'
alg: number
}
export interface AuthenticatorSelectionCriteriaJSON {
authenticatorAttachment?: AuthenticatorAttachment
residentKey?: ResidentKeyRequirement
requireResidentKey?: boolean
userVerification?: UserVerificationRequirement
}
export interface PublicKeyCredentialCreationOptionsJSON {
rp: PublicKeyCredentialRpEntityJSON
user: PublicKeyCredentialUserEntityJSON
challenge: string
pubKeyCredParams: PublicKeyCredentialParametersJSON[]
timeout?: number
excludeCredentials?: PublicKeyCredentialDescriptorJSON[]
authenticatorSelection?: AuthenticatorSelectionCriteriaJSON
attestation?: AttestationConveyancePreference
}
export interface PublicKeyCredentialRequestOptionsJSON {
challenge: string
timeout?: number
rpId?: string
allowCredentials?: PublicKeyCredentialDescriptorJSON[]
userVerification?: UserVerificationRequirement
}
export interface WebAuthnCreationOptionsResponse {
publicKey: PublicKeyCredentialCreationOptionsJSON
}
export interface WebAuthnRequestOptionsResponse {
publicKey: PublicKeyCredentialRequestOptionsJSON
}
export interface CredentialAssertionJSON {
id: string
type: string
rawId: string
response: {
clientDataJSON: string
authenticatorData: string
signature: string
userHandle: string | null
}
}
export interface CredentialAttestationJSON {
id: string
type: string
rawId: string
response: {
clientDataJSON: string
attestationObject: string
}
}
export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(padded)
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer
}
export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
export function prepareCreationOptions(
options: WebAuthnCreationOptionsResponse
): PublicKeyCredentialCreationOptions {
const pk = options.publicKey
return {
...pk,
challenge: base64UrlToArrayBuffer(pk.challenge),
user: {
...pk.user,
id: base64UrlToArrayBuffer(pk.user.id),
},
excludeCredentials: (pk.excludeCredentials ?? []).map((cred) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id),
})),
}
}
export function prepareRequestOptions(
options: WebAuthnRequestOptionsResponse
): PublicKeyCredentialRequestOptions {
const pk = options.publicKey
return {
...pk,
challenge: base64UrlToArrayBuffer(pk.challenge),
allowCredentials: (pk.allowCredentials ?? []).map((cred) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id),
})),
}
}
export function serializeAttestationResponse(
credential: PublicKeyCredential
): CredentialAttestationJSON {
const response = credential.response as AuthenticatorAttestationResponse
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
attestationObject: arrayBufferToBase64Url(response.attestationObject),
},
}
}
export function serializeAssertionResponse(
credential: PublicKeyCredential
): CredentialAssertionJSON {
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle
? arrayBufferToBase64Url(response.userHandle)
: null,
},
}
}
+18 -6
View File
@@ -1,10 +1,22 @@
<script lang="ts">
import { getAuthState, logout } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let error = $state<string | null>(null)
let loading = $state(true)
let actAsInProgress = $state(false)
@@ -15,13 +27,13 @@
}
$effect(() => {
if (!auth.loading && !auth.session && !actAsInProgress) {
navigate('/login')
if (!authLoading && !session && !actAsInProgress) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session && !actAsInProgress) {
if (session && !actAsInProgress) {
actAsInProgress = true
initiateActAs()
}
@@ -39,7 +51,7 @@
const response = await fetch(
`/xrpc/_delegation.listControlledAccounts`,
{
headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` }
headers: { 'Authorization': `Bearer ${session!.accessJwt}` }
}
)
+54 -62
View File
@@ -1,11 +1,25 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDate, formatDateTime } from '../lib/date'
const auth = getAuthState()
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
const DEFAULT_COLORS = {
primaryLight: '#1A1D1D',
primaryDark: '#E6E8E8',
@@ -13,7 +27,6 @@
secondaryDark: '#E6E8E8',
}
let loading = $state(true)
let error = $state<string | null>(null)
let stats = $state<{
userCount: number
repoCount: number
@@ -21,7 +34,6 @@
blobStorageBytes: number
} | null>(null)
let usersLoading = $state(false)
let usersError = $state<string | null>(null)
let users = $state<Array<{
did: string
handle: string
@@ -34,7 +46,6 @@
let handleSearchQuery = $state('')
let showUsers = $state(false)
let invitesLoading = $state(false)
let invitesError = $state<string | null>(null)
let invites = $state<Array<{
code: string
available: number
@@ -72,17 +83,15 @@
let logoFile = $state<File | null>(null)
let logoPreview = $state<string | null>(null)
let serverConfigLoading = $state(false)
let serverConfigError = $state<string | null>(null)
let serverConfigSuccess = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
} else if (!auth.loading && auth.session && !auth.session.isAdmin) {
navigate('/dashboard')
if (!authLoading && !session) {
navigate(routes.login)
} else if (!authLoading && session && !session.isAdmin) {
navigate(routes.dashboard)
}
})
$effect(() => {
if (auth.session?.isAdmin) {
if (session?.isAdmin) {
loadStats()
loadServerConfig()
}
@@ -106,22 +115,20 @@
logoPreview = '/logo'
}
} catch (e) {
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadConfig'))
}
}
async function saveServerConfig(e: Event) {
e.preventDefault()
if (!auth.session) return
if (!session) return
serverConfigLoading = true
serverConfigError = null
serverConfigSuccess = false
try {
let newLogoCid = logoCid
if (logoFile) {
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
const result = await api.uploadBlob(session.accessJwt, logoFile)
newLogoCid = result.blob.ref.$link
}
await api.updateServerConfig(auth.session.accessJwt, {
await api.updateServerConfig(session.accessJwt, {
serverName: serverNameInput,
primaryColor: primaryColorInput,
primaryColorDark: primaryColorDarkInput,
@@ -145,10 +152,9 @@
secondaryColorDark: secondaryColorDarkInput || null,
})
setGlobalHasLogo(!!newLogoCid)
serverConfigSuccess = true
setTimeout(() => { serverConfigSuccess = false }, 3000)
toast.success($_('admin.configSaved'))
} catch (e) {
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToSaveConfig'))
} finally {
serverConfigLoading = false
}
@@ -179,27 +185,25 @@
logoChanged
}
async function loadStats() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
stats = await api.getServerStats(auth.session.accessJwt)
stats = await api.getServerStats(session.accessJwt)
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load server stats'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadStats'))
} finally {
loading = false
}
}
async function loadUsers(reset = false) {
if (!auth.session) return
if (!session) return
usersLoading = true
usersError = null
if (reset) {
users = []
usersCursor = undefined
}
try {
const result = await api.searchAccounts(auth.session.accessJwt, {
const result = await api.searchAccounts(session.accessJwt, {
handle: handleSearchQuery || undefined,
cursor: reset ? undefined : usersCursor,
limit: 25,
@@ -208,7 +212,7 @@
usersCursor = result.cursor
showUsers = true
} catch (e) {
usersError = e instanceof ApiError ? e.message : 'Failed to load users'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUsers'))
} finally {
usersLoading = false
}
@@ -218,15 +222,14 @@
loadUsers(true)
}
async function loadInvites(reset = false) {
if (!auth.session) return
if (!session) return
invitesLoading = true
invitesError = null
if (reset) {
invites = []
invitesCursor = undefined
}
try {
const result = await api.getInviteCodes(auth.session.accessJwt, {
const result = await api.getInviteCodes(session.accessJwt, {
cursor: reset ? undefined : invitesCursor,
limit: 25,
})
@@ -234,28 +237,29 @@
invitesCursor = result.cursor
showInvites = true
} catch (e) {
invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadInvites'))
} finally {
invitesLoading = false
}
}
async function disableInvite(code: string) {
if (!auth.session) return
if (!session) return
if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
try {
await api.disableInviteCodes(auth.session.accessJwt, [code])
await api.disableInviteCodes(session.accessJwt, [code])
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
toast.success($_('admin.inviteDisabled'))
} catch (e) {
invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite'))
}
}
async function selectUser(did: string) {
if (!auth.session) return
if (!session) return
userDetailLoading = true
try {
selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
selectedUser = await api.getAccountInfo(session.accessJwt, did)
} catch (e) {
usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails'))
} finally {
userDetailLoading = false
}
@@ -264,32 +268,35 @@
selectedUser = null
}
async function toggleUserInvites() {
if (!auth.session || !selectedUser) return
if (!session || !selectedUser) return
userActionLoading = true
try {
if (selectedUser.invitesDisabled) {
await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
await api.enableAccountInvites(session.accessJwt, selectedUser.did)
selectedUser = { ...selectedUser, invitesDisabled: false }
toast.success($_('admin.invitesEnabled'))
} else {
await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
await api.disableAccountInvites(session.accessJwt, selectedUser.did)
selectedUser = { ...selectedUser, invitesDisabled: true }
toast.success($_('admin.invitesDisabled'))
}
} catch (e) {
usersError = e instanceof ApiError ? e.message : 'Failed to update user'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToUpdateUser'))
} finally {
userActionLoading = false
}
}
async function deleteUser() {
if (!auth.session || !selectedUser) return
if (!session || !selectedUser) return
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
userActionLoading = true
try {
await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
await api.adminDeleteAccount(session.accessJwt, selectedUser.did)
users = users.filter(u => u.did !== selectedUser!.did)
selectedUser = null
toast.success($_('admin.userDeleted'))
} catch (e) {
usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDeleteUser'))
} finally {
userActionLoading = false
}
@@ -305,7 +312,7 @@
return num.toLocaleString()
}
</script>
{#if auth.session?.isAdmin}
{#if session?.isAdmin}
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
@@ -314,9 +321,6 @@
{#if loading}
<p class="loading">{$_('admin.loading')}</p>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
<section>
<h2>{$_('admin.serverConfig')}</h2>
<form class="config-form" onsubmit={saveServerConfig}>
@@ -428,12 +432,6 @@
</div>
</div>
{#if serverConfigError}
<div class="message error">{serverConfigError}</div>
{/if}
{#if serverConfigSuccess}
<div class="message success">{$_('admin.configSaved')}</div>
{/if}
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
</button>
@@ -476,9 +474,6 @@
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
</button>
</form>
{#if usersError}
<div class="message error">{usersError}</div>
{/if}
{#if showUsers}
<div class="user-list">
{#if users.length === 0}
@@ -528,9 +523,6 @@
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
</button>
</div>
{#if invitesError}
<div class="message error">{invitesError}</div>
{/if}
{#if showInvites}
<div class="invite-list">
{#if invites.length === 0}
+46 -23
View File
@@ -1,13 +1,26 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, type AppPassword, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDate } from '../lib/date'
const auth = getAuthState()
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let passwords = $state<AppPassword[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let newPasswordName = $state('')
let selectedScope = $state<string | null>(null)
let creating = $state(false)
@@ -29,58 +42,56 @@
return $_('appPasswords.scopeCustom')
}
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadPasswords()
}
})
async function loadPasswords() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const result = await api.listAppPasswords(auth.session.accessJwt)
const result = await api.listAppPasswords(session.accessJwt)
passwords = result.passwords
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load app passwords'
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToLoad'))
} finally {
loading = false
}
}
async function handleCreate(e: Event) {
e.preventDefault()
if (!auth.session || !newPasswordName.trim()) return
if (!session || !newPasswordName.trim()) return
creating = true
error = null
try {
const scopeValue = selectedScope === null ? undefined : selectedScope
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
const result = await api.createAppPassword(session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
createdPassword = { name: result.name, password: result.password }
newPasswordName = ''
selectedScope = null
await loadPasswords()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create app password'
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToCreate'))
} finally {
creating = false
}
}
async function handleRevoke(name: string) {
if (!auth.session) return
if (!session) return
if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) {
return
}
revoking = name
error = null
try {
await api.revokeAppPassword(auth.session.accessJwt, name)
await api.revokeAppPassword(session.accessJwt, name)
await loadPasswords()
toast.success($_('appPasswords.passwordRevoked'))
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to revoke app password'
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToRevoke'))
} finally {
revoking = null
}
@@ -99,15 +110,12 @@
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('appPasswords.title')}</h1>
</header>
<p class="description">
{$_('appPasswords.description')}
</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if createdPassword}
<div class="created-password">
<div class="warning-box">
@@ -162,7 +170,11 @@
<section class="list-section">
<h2>{$_('appPasswords.yourPasswords')}</h2>
{#if loading}
<p class="empty">{$_('common.loading')}</p>
<ul class="password-list">
{#each Array(2) as _}
<li class="skeleton-item"></li>
{/each}
</ul>
{:else if passwords.length === 0}
<p class="empty">{$_('appPasswords.noPasswords')}</p>
{:else}
@@ -459,4 +471,15 @@
text-align: center;
padding: var(--space-7);
}
.skeleton-item {
height: 60px;
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+56 -47
View File
@@ -1,14 +1,26 @@
<script lang="ts">
import { getAuthState, refreshSession } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
const auth = getAuthState()
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let saving = $state(false)
let error = $state<string | null>(null)
let success = $state<string | null>(null)
let preferredChannel = $state('email')
let availableCommsChannels = $state<string[]>(['email'])
let email = $state('')
@@ -20,10 +32,7 @@
let signalVerified = $state(false)
let verifyingChannel = $state<string | null>(null)
let verificationCode = $state('')
let verificationError = $state<string | null>(null)
let verificationSuccess = $state<string | null>(null)
let historyLoading = $state(true)
let historyError = $state<string | null>(null)
let messages = $state<Array<{
createdAt: string
channel: string
@@ -33,23 +42,22 @@
body: string
}>>([])
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadPrefs()
loadHistory()
}
})
async function loadPrefs() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const [prefs, serverInfo] = await Promise.all([
api.getNotificationPrefs(auth.session.accessJwt),
api.getNotificationPrefs(session.accessJwt),
api.describeServer()
])
preferredChannel = prefs.preferredChannel
@@ -62,37 +70,33 @@
signalVerified = prefs.signalVerified
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
} finally {
loading = false
}
}
async function handleSave(e: Event) {
e.preventDefault()
if (!auth.session) return
if (!session) return
saving = true
error = null
success = null
try {
await api.updateNotificationPrefs(auth.session.accessJwt, {
await api.updateNotificationPrefs(session.accessJwt, {
preferredChannel,
discordId: discordId || undefined,
telegramUsername: telegramUsername || undefined,
signalNumber: signalNumber || undefined,
})
await refreshSession()
success = $_('comms.preferencesSaved')
toast.success($_('comms.preferencesSaved'))
await loadPrefs()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save preferences'
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
} finally {
saving = false
}
}
async function handleVerify(channel: string) {
if (!auth.session || !verificationCode) return
verificationError = null
verificationSuccess = null
if (!session || !verificationCode) return
let identifier = ''
switch (channel) {
@@ -103,25 +107,24 @@
if (!identifier) return
try {
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
await api.confirmChannelVerification(session.accessJwt, channel, identifier, verificationCode)
await refreshSession()
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
toast.success($_('comms.verifiedSuccess', { values: { channel } }))
verificationCode = ''
verifyingChannel = null
await loadPrefs()
} catch (e) {
verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToVerify'))
}
}
async function loadHistory() {
if (!auth.session) return
if (!session) return
historyLoading = true
historyError = null
try {
const result = await api.getNotificationHistory(auth.session.accessJwt)
const result = await api.getNotificationHistory(session.accessJwt)
messages = result.notifications
} catch (e) {
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoadHistory'))
} finally {
historyLoading = false
}
@@ -168,21 +171,17 @@
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('comms.title')}</h1>
<p class="description">{$_('comms.description')}</p>
</header>
{#if loading}
<p class="loading">{$_('common.loading')}</p>
<div class="skeleton-sections">
<div class="skeleton-section"></div>
<div class="skeleton-section"></div>
</div>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
{#if success}
<div class="message success">{success}</div>
{/if}
<div class="split-layout">
<div class="main-column">
<form onsubmit={handleSave}>
@@ -331,12 +330,6 @@
</div>
</div>
{#if verificationError}
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
{/if}
{#if verificationSuccess}
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
{/if}
</section>
<div class="actions">
@@ -364,8 +357,6 @@
</div>
{/each}
</div>
{:else if historyError}
<div class="message error">{historyError}</div>
{:else if messages.length === 0}
<p class="no-messages">{$_('comms.noMessages')}</p>
{:else}
@@ -790,4 +781,22 @@
color: var(--text-muted);
margin-top: var(--space-2);
}
.skeleton-sections {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.skeleton-section {
height: 180px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+62 -43
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
interface Controller {
did: string
@@ -26,10 +28,20 @@
scopes: string
}
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let error = $state<string | null>(null)
let success = $state<string | null>(null)
let controllers = $state<Controller[]>([])
let controlledAccounts = $state<ControlledAccount[]>([])
let scopePresets = $state<ScopePreset[]>([])
@@ -51,20 +63,19 @@
let creatingDelegated = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadData()
}
})
async function loadData() {
loading = true
error = null
try {
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
} finally {
@@ -73,10 +84,10 @@
}
async function loadControllers() {
if (!auth.session) return
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControllers', {
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
@@ -88,10 +99,10 @@
}
async function loadControlledAccounts() {
if (!auth.session) return
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
@@ -115,16 +126,14 @@
}
async function addController() {
if (!auth.session || !addControllerDid.trim()) return
if (!session || !addControllerDid.trim()) return
addingController = true
error = null
success = null
try {
const response = await fetch('/xrpc/_delegation.addController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.session.accessJwt}`,
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -135,34 +144,31 @@
if (!response.ok) {
const data = await response.json()
error = data.message || data.error || $_('delegation.failedToAddController')
toast.error(data.message || data.error || $_('delegation.failedToAddController'))
return
}
success = $_('delegation.controllerAdded')
toast.success($_('delegation.controllerAdded'))
addControllerDid = ''
addControllerScopes = 'atproto'
showAddController = false
await loadControllers()
} catch (e) {
error = $_('delegation.failedToAddController')
toast.error($_('delegation.failedToAddController'))
} finally {
addingController = false
}
}
async function removeController(controllerDid: string) {
if (!auth.session) return
if (!session) return
if (!confirm($_('delegation.removeConfirm'))) return
error = null
success = null
try {
const response = await fetch('/xrpc/_delegation.removeController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.session.accessJwt}`,
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ controller_did: controllerDid })
@@ -170,28 +176,26 @@
if (!response.ok) {
const data = await response.json()
error = data.message || data.error || $_('delegation.failedToRemoveController')
toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
return
}
success = $_('delegation.controllerRemoved')
toast.success($_('delegation.controllerRemoved'))
await loadControllers()
} catch (e) {
error = $_('delegation.failedToRemoveController')
toast.error($_('delegation.failedToRemoveController'))
}
}
async function createDelegatedAccount() {
if (!auth.session || !newDelegatedHandle.trim()) return
if (!session || !newDelegatedHandle.trim()) return
creatingDelegated = true
error = null
success = null
try {
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.session.accessJwt}`,
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -203,19 +207,19 @@
if (!response.ok) {
const data = await response.json()
error = data.message || data.error || $_('delegation.failedToCreateAccount')
toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
return
}
const data = await response.json()
success = $_('delegation.accountCreated', { values: { handle: data.handle } })
toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
newDelegatedHandle = ''
newDelegatedEmail = ''
newDelegatedScopes = 'atproto'
showCreateDelegated = false
await loadControlledAccounts()
} catch (e) {
error = $_('delegation.failedToCreateAccount')
toast.error($_('delegation.failedToCreateAccount'))
} finally {
creatingDelegated = false
}
@@ -237,16 +241,12 @@
</header>
{#if loading}
<p class="loading">{$_('delegation.loading')}</p>
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
{#if success}
<div class="message success">{success}</div>
{/if}
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controllers')}</h2>
@@ -677,4 +677,23 @@
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.skeleton-card {
height: 120px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+106 -66
View File
@@ -1,16 +1,40 @@
<script lang="ts">
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import {
getAuthState,
logout,
switchAccount,
type SavedAccount,
} from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { api } from '../lib/api'
import { isOk } from '../lib/types/result'
import { unsafeAsDid, type Did } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { onMount } from 'svelte'
const auth = getAuthState()
const auth = $derived(getAuthState())
let dropdownOpen = $state(false)
let switching = $state(false)
let inviteCodesEnabled = $state(false)
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function getSavedAccounts(): readonly SavedAccount[] {
return auth.savedAccounts
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const savedAccounts = $derived(getSavedAccounts())
const loading = $derived(isLoading())
const isDidWeb = $derived(session?.did?.startsWith('did:web:') ?? false)
const otherAccounts = $derived(savedAccounts.filter(a => a.did !== session?.did))
onMount(async () => {
try {
@@ -22,26 +46,24 @@
})
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!loading && !session) {
navigate(routes.login)
}
})
async function handleLogout() {
await logout()
navigate('/login')
navigate(routes.login)
}
async function handleSwitchAccount(did: string) {
async function handleSwitchAccount(did: Did) {
switching = true
dropdownOpen = false
try {
await switchAccount(did)
} catch {
navigate('/login')
} finally {
switching = false
const result = await switchAccount(did)
if (!isOk(result)) {
navigate(routes.login)
}
switching = false
}
function toggleDropdown() {
@@ -61,19 +83,15 @@
return () => document.removeEventListener('click', closeDropdown)
}
})
let otherAccounts = $derived(
auth.savedAccounts.filter(a => a.did !== auth.session?.did)
)
</script>
{#if auth.session}
{#if session}
<div class="dashboard">
<header>
<h1>{$_('dashboard.title')}</h1>
<div class="account-dropdown">
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
<span class="account-handle">@{auth.session.handle}</span>
<span class="account-handle">@{session.handle}</span>
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
</button>
{#if dropdownOpen}
@@ -89,24 +107,24 @@
</div>
<div class="dropdown-divider"></div>
{/if}
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate('/login') }}>
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate(routes.login) }}>
{$_('dashboard.addAnotherAccount')}
</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
{$_('dashboard.signOut', { values: { handle: auth.session.handle } })}
{$_('dashboard.signOut', { values: { handle: session.handle } })}
</button>
</div>
{/if}
</div>
</header>
{#if auth.session.status === 'migrated'}
{#if session.status === 'migrated'}
<div class="migrated-banner">
<strong>{$_('dashboard.migratedTitle')}</strong>
<p>{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}</p>
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
</div>
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
{:else if session.status === 'deactivated' || session.active === false}
<div class="deactivated-banner">
<strong>{$_('dashboard.deactivatedTitle')}</strong>
<p>{$_('dashboard.deactivatedMessage')}</p>
@@ -118,43 +136,43 @@
<dl>
<dt>{$_('dashboard.handle')}</dt>
<dd>
@{auth.session.handle}
{#if auth.session.isAdmin}
@{session.handle}
{#if session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if auth.session.status === 'migrated'}
{#if session.status === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
{:else if session.status === 'deactivated' || session.active === false}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
<dt>{$_('dashboard.did')}</dt>
<dd class="mono">{auth.session.did}</dd>
{#if auth.session.preferredChannel}
<dd class="mono">{session.did}</dd>
{#if session.preferredChannel}
<dt>{$_('dashboard.primaryContact')}</dt>
<dd>
{#if auth.session.preferredChannel === 'email'}
{auth.session.email || $_('register.email')}
{:else if auth.session.preferredChannel === 'discord'}
{#if session.preferredChannel === 'email'}
{session.email || $_('register.email')}
{:else if session.preferredChannel === 'discord'}
{$_('register.discord')}
{:else if auth.session.preferredChannel === 'telegram'}
{:else if session.preferredChannel === 'telegram'}
{$_('register.telegram')}
{:else if auth.session.preferredChannel === 'signal'}
{:else if session.preferredChannel === 'signal'}
{$_('register.signal')}
{:else}
{auth.session.preferredChannel}
{session.preferredChannel}
{/if}
{#if auth.session.preferredChannelVerified}
{#if session.preferredChannelVerified}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{:else if auth.session.email}
{:else if session.email}
<dt>{$_('register.email')}</dt>
<dd>
{auth.session.email}
{#if auth.session.emailConfirmed}
{session.email}
{#if session.emailConfirmed}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
@@ -165,74 +183,74 @@
</section>
<nav class="nav-grid">
{#if auth.session.status === 'migrated'}
<a href="/app/did-document" class="nav-card migrated-card">
{#if session.status === 'migrated'}
<a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card">
<h3>{$_('dashboard.navDidDocument')}</h3>
<p>{$_('dashboard.navDidDocumentDesc')}</p>
</a>
<a href="/app/sessions" class="nav-card">
<a href={getFullUrl(routes.sessions)} class="nav-card">
<h3>{$_('dashboard.navSessions')}</h3>
<p>{$_('dashboard.navSessionsDesc')}</p>
</a>
<a href="/app/security" class="nav-card">
<a href={getFullUrl(routes.security)} class="nav-card">
<h3>{$_('dashboard.navSecurity')}</h3>
<p>{$_('dashboard.navSecurityDesc')}</p>
</a>
<a href="/app/settings" class="nav-card">
<a href={getFullUrl(routes.settings)} class="nav-card">
<h3>{$_('dashboard.navSettings')}</h3>
<p>{$_('dashboard.navSettingsDesc')}</p>
</a>
<a href="/app/migrate" class="nav-card">
<a href={getFullUrl(routes.migrate)} class="nav-card">
<h3>{$_('dashboard.navMigrateAgain')}</h3>
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
</a>
{:else}
<a href="/app/app-passwords" class="nav-card">
<a href={getFullUrl(routes.appPasswords)} class="nav-card">
<h3>{$_('dashboard.navAppPasswords')}</h3>
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
</a>
<a href="/app/sessions" class="nav-card">
<a href={getFullUrl(routes.sessions)} class="nav-card">
<h3>{$_('dashboard.navSessions')}</h3>
<p>{$_('dashboard.navSessionsDesc')}</p>
</a>
{#if inviteCodesEnabled && auth.session.isAdmin}
<a href="/app/invite-codes" class="nav-card">
{#if inviteCodesEnabled && session.isAdmin}
<a href={getFullUrl(routes.inviteCodes)} class="nav-card">
<h3>{$_('dashboard.navInviteCodes')}</h3>
<p>{$_('dashboard.navInviteCodesDesc')}</p>
</a>
{/if}
<a href="/app/settings" class="nav-card">
<a href={getFullUrl(routes.settings)} class="nav-card">
<h3>{$_('dashboard.navSettings')}</h3>
<p>{$_('dashboard.navSettingsDesc')}</p>
</a>
<a href="/app/security" class="nav-card">
<a href={getFullUrl(routes.security)} class="nav-card">
<h3>{$_('dashboard.navSecurity')}</h3>
<p>{$_('dashboard.navSecurityDesc')}</p>
</a>
<a href="/app/comms" class="nav-card">
<a href={getFullUrl(routes.comms)} class="nav-card">
<h3>{$_('dashboard.navComms')}</h3>
<p>{$_('dashboard.navCommsDesc')}</p>
</a>
<a href="/app/repo" class="nav-card">
<a href={getFullUrl(routes.repo)} class="nav-card">
<h3>{$_('dashboard.navRepo')}</h3>
<p>{$_('dashboard.navRepoDesc')}</p>
</a>
<a href="/app/controllers" class="nav-card">
<a href={getFullUrl(routes.controllers)} class="nav-card">
<h3>{$_('dashboard.navDelegation')}</h3>
<p>{$_('dashboard.navDelegationDesc')}</p>
</a>
{#if isDidWeb}
<a href="/app/did-document" class="nav-card did-web-card">
<a href={getFullUrl(routes.didDocument)} class="nav-card did-web-card">
<h3>{$_('dashboard.navDidDocument')}</h3>
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
</a>
{/if}
<a href="/app/migrate" class="nav-card">
<a href={getFullUrl(routes.migrate)} class="nav-card">
<h3>{$_('migration.navTitle')}</h3>
<p>{$_('migration.navDesc')}</p>
</a>
{#if auth.session.isAdmin}
<a href="/app/admin" class="nav-card admin-card">
{#if session.isAdmin}
<a href={getFullUrl(routes.admin)} class="nav-card admin-card">
<h3>{$_('dashboard.navAdmin')}</h3>
<p>{$_('dashboard.navAdminDesc')}</p>
</a>
@@ -240,8 +258,15 @@
{/if}
</nav>
</div>
{:else if auth.loading}
<div class="loading">{$_('common.loading')}</div>
{:else if loading}
<div class="dashboard">
<div class="skeleton-section"></div>
<nav class="nav-grid">
{#each Array(6) as _}
<div class="skeleton-card"></div>
{/each}
</nav>
</div>
{/if}
<style>
@@ -460,10 +485,25 @@
box-shadow: 0 2px 12px var(--accent-muted);
}
.loading {
text-align: center;
padding: var(--space-9);
color: var(--text-secondary);
.skeleton-section {
height: 140px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
margin-bottom: var(--space-7);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-card {
height: 100px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.deactivated-banner {
+50 -22
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
interface AuditEntry {
id: string
@@ -14,42 +16,52 @@
createdAt: string
}
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let error = $state<string | null>(null)
let entries = $state<AuditEntry[]>([])
let total = $state(0)
let offset = $state(0)
const limit = 20
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadAuditLog()
}
})
async function loadAuditLog() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const response = await fetch(
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
{
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
}
)
if (!response.ok) {
const data = await response.json()
error = data.message || data.error || $_('delegation.failedToLoadAuditLog')
toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog'))
return
}
@@ -57,7 +69,7 @@
entries = data.entries || []
total = data.total || 0
} catch (e) {
error = $_('delegation.failedToLoadAuditLog')
toast.error($_('delegation.failedToLoadAuditLog'))
} finally {
loading = false
}
@@ -92,12 +104,9 @@
function formatActionDetails(details: Record<string, unknown> | null): string {
if (!details) return ''
const parts: string[] = []
for (const [key, value] of Object.entries(details)) {
const formattedKey = key.replace(/_/g, ' ')
parts.push(`${formattedKey}: ${JSON.stringify(value)}`)
}
return parts.join(', ')
return Object.entries(details)
.map(([key, value]) => `${key.replace(/_/g, ' ')}: ${JSON.stringify(value)}`)
.join(', ')
}
function truncateDid(did: string): string {
@@ -113,12 +122,12 @@
</header>
{#if loading}
<p class="loading">{$_('delegation.loading')}</p>
<div class="skeleton-list">
{#each Array(3) as _}
<div class="skeleton-entry"></div>
{/each}
</div>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
{#if entries.length === 0}
<p class="empty">{$_('delegation.noActivity')}</p>
{:else}
@@ -319,4 +328,23 @@
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.skeleton-entry {
height: 100px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+59 -28
View File
@@ -1,15 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let saving = $state(false)
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let didDocument = $state<DidDocument | null>(null)
let verificationMethods = $state<VerificationMethod[]>([])
let alsoKnownAs = $state<string[]>([])
@@ -19,15 +31,15 @@
let newHandle = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
onMount(async () => {
if (!auth.session) return
if (!session) return
try {
didDocument = await api.getDidDocument(auth.session.accessJwt)
didDocument = await api.getDidDocument(session.accessJwt)
verificationMethods = didDocument.verificationMethod.map(vm => ({
id: vm.id.replace(didDocument!.id, ''),
type: vm.type,
@@ -37,23 +49,16 @@
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
serviceEndpoint = pdsService?.serviceEndpoint || ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
toast.error(e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
} finally {
loading = false
}
})
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
function addVerificationMethod() {
if (!newKeyId || !newKeyPublic) return
if (!newKeyPublic.startsWith('z')) {
showMessage('error', $_('didEditor.invalidMultibase'))
toast.error($_('didEditor.invalidMultibase'))
return
}
verificationMethods = [...verificationMethods, {
@@ -72,7 +77,7 @@
function addHandle() {
if (!newHandle) return
if (!newHandle.startsWith('at://')) {
showMessage('error', $_('didEditor.invalidHandle'))
toast.error($_('didEditor.invalidHandle'))
return
}
alsoKnownAs = [...alsoKnownAs, newHandle]
@@ -84,19 +89,18 @@
}
async function handleSave() {
if (!auth.session) return
if (!session) return
saving = true
message = null
try {
await api.updateDidDocument(auth.session.accessJwt, {
await api.updateDidDocument(session.accessJwt, {
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
serviceEndpoint: serviceEndpoint || undefined
})
showMessage('success', $_('didEditor.success'))
didDocument = await api.getDidDocument(auth.session.accessJwt)
toast.success($_('didEditor.success'))
didDocument = await api.getDidDocument(session.accessJwt)
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
toast.error(e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
} finally {
saving = false
}
@@ -109,12 +113,13 @@
<h1>{$_('didEditor.title')}</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
{#if loading}
<div class="loading">{$_('common.loading')}</div>
<div class="skeleton-sections">
<div class="skeleton-section small"></div>
<div class="skeleton-section large"></div>
<div class="skeleton-section"></div>
<div class="skeleton-section"></div>
</div>
{:else}
<div class="help-section">
<h3>{$_('didEditor.helpTitle')}</h3>
@@ -454,4 +459,30 @@
width: 100%;
}
}
.skeleton-sections {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.skeleton-section {
height: 180px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-section.small {
height: 80px;
}
.skeleton-section.large {
height: 250px;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+44 -22
View File
@@ -1,15 +1,27 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, type InviteCode, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDate } from '../lib/date'
import { onMount } from 'svelte'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let codes = $state<InviteCode[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let creating = $state(false)
let createdCode = $state<string | null>(null)
let createdCodeCopied = $state(false)
@@ -21,46 +33,44 @@
const serverInfo = await api.describeServer()
inviteCodesEnabled = serverInfo.inviteCodeRequired
if (!serverInfo.inviteCodeRequired) {
navigate('/dashboard')
navigate(routes.dashboard)
}
} catch {
navigate('/dashboard')
navigate(routes.dashboard)
}
})
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session && inviteCodesEnabled) {
if (session && inviteCodesEnabled) {
loadCodes()
}
})
async function loadCodes() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
const result = await api.getAccountInviteCodes(session.accessJwt)
codes = result.codes
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load invite codes'
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToLoad'))
} finally {
loading = false
}
}
async function handleCreate() {
if (!auth.session) return
if (!session) return
creating = true
error = null
try {
const result = await api.createInviteCode(auth.session.accessJwt, 1)
const result = await api.createInviteCode(session.accessJwt, 1)
createdCode = result.code
await loadCodes()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create invite code'
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToCreate'))
} finally {
creating = false
}
@@ -87,15 +97,12 @@
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('inviteCodes.title')}</h1>
</header>
<p class="description">
{$_('inviteCodes.description')}
</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if createdCode}
<div class="created-code">
<h3>{$_('inviteCodes.created')}</h3>
@@ -108,7 +115,7 @@
<button onclick={dismissCreated}>{$_('common.done')}</button>
</div>
{/if}
{#if auth.session?.isAdmin}
{#if session?.isAdmin}
<section class="create-section">
<button onclick={handleCreate} disabled={creating}>
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
@@ -118,7 +125,11 @@
<section class="list-section">
<h2>{$_('inviteCodes.yourCodes')}</h2>
{#if loading}
<p class="empty">{$_('common.loading')}</p>
<ul class="code-list">
{#each Array(2) as _}
<li class="skeleton-item"></li>
{/each}
</ul>
{:else if codes.length === 0}
<p class="empty">{$_('inviteCodes.noCodes')}</p>
{:else}
@@ -325,4 +336,15 @@
text-align: center;
padding: var(--space-7);
}
.skeleton-item {
height: 50px;
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+73 -35
View File
@@ -1,86 +1,124 @@
<script lang="ts">
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import {
loginWithOAuth,
confirmSignup,
resendVerification,
getAuthState,
switchAccount,
forgetAccount,
matchAuthState,
type SavedAccount,
type AuthError,
} from '../lib/auth.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { isOk, isErr } from '../lib/types/result'
import { unsafeAsDid, type Did } from '../lib/types/branded'
type PageState =
| { kind: 'login' }
| { kind: 'verification'; did: string }
let pageState = $state<PageState>({ kind: 'login' })
let submitting = $state(false)
let pendingVerification = $state<{ did: string } | null>(null)
let verificationCode = $state('')
let resendingCode = $state(false)
let resendMessage = $state<string | null>(null)
let autoRedirectAttempted = $state(false)
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSavedAccounts(): readonly SavedAccount[] {
return auth.savedAccounts
}
function getErrorMessage(): string | null {
if (auth.kind === 'error') {
return auth.error.message
}
return null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
$effect(() => {
if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) {
const accounts = getSavedAccounts()
const loading = isLoading()
const hasError = auth.kind === 'error'
if (!loading && !hasError && accounts.length === 0 && pageState.kind === 'login' && !autoRedirectAttempted) {
autoRedirectAttempted = true
loginWithOAuth()
}
})
async function handleSwitchAccount(did: string) {
async function handleSwitchAccount(did: Did) {
submitting = true
try {
await switchAccount(did)
navigate('/dashboard')
} catch {
const result = await switchAccount(did)
if (isOk(result)) {
navigate(routes.dashboard)
} else {
submitting = false
}
}
function handleForgetAccount(did: string, e: Event) {
function handleForgetAccount(did: Did, e: Event) {
e.stopPropagation()
forgetAccount(did)
}
async function handleOAuthLogin() {
submitting = true
try {
await loginWithOAuth()
} catch {
const result = await loginWithOAuth()
if (isErr(result)) {
submitting = false
}
}
async function handleVerification(e: Event) {
e.preventDefault()
if (!pendingVerification || !verificationCode.trim()) return
if (pageState.kind !== 'verification' || !verificationCode.trim()) return
submitting = true
try {
await confirmSignup(pendingVerification.did, verificationCode.trim())
navigate('/dashboard')
} catch {
const result = await confirmSignup(pageState.did, verificationCode.trim())
if (isOk(result)) {
navigate(routes.dashboard)
} else {
submitting = false
}
}
async function handleResendCode() {
if (!pendingVerification || resendingCode) return
if (pageState.kind !== 'verification' || resendingCode) return
resendingCode = true
resendMessage = null
try {
await resendVerification(pendingVerification.did)
const result = await resendVerification(pageState.did)
if (isOk(result)) {
resendMessage = $_('verification.resent')
} catch {
resendMessage = null
} finally {
resendingCode = false
}
resendingCode = false
}
function backToLogin() {
pendingVerification = null
pageState = { kind: 'login' }
verificationCode = ''
resendMessage = null
}
const errorMessage = $derived(getErrorMessage())
const savedAccounts = $derived(getSavedAccounts())
const loading = $derived(isLoading())
</script>
<div class="login-page">
{#if auth.error}
<div class="message error">{auth.error}</div>
{#if errorMessage}
<div class="message error">{errorMessage}</div>
{/if}
{#if pendingVerification}
{#if pageState.kind === 'verification'}
<header class="page-header">
<h1>{$_('verification.title')}</h1>
<p class="subtitle">{$_('verification.subtitle')}</p>
@@ -121,14 +159,14 @@
{:else}
<header class="page-header">
<h1>{$_('login.title')}</h1>
<p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
<p class="subtitle">{savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
</header>
<div class="split-layout sidebar-right">
<div class="main-section">
{#if auth.savedAccounts.length > 0}
{#if savedAccounts.length > 0}
<div class="saved-accounts">
{#each auth.savedAccounts as account}
{#each savedAccounts as account}
<div
class="account-item"
class:disabled={submitting}
@@ -156,7 +194,7 @@
<p class="or-divider">{$_('login.signInToAnother')}</p>
{/if}
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}>
{submitting ? $_('login.redirecting') : $_('login.button')}
</button>
@@ -172,7 +210,7 @@
</div>
<aside class="info-panel">
{#if auth.savedAccounts.length > 0}
{#if savedAccounts.length > 0}
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
<p>{$_('login.infoSavedAccountsDesc')}</p>
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { setSession } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import {
createInboundMigrationFlow,
@@ -151,7 +151,7 @@
refreshJwt: '',
})
}
navigate('/dashboard')
navigate(routes.dashboard)
}
function handleOfflineComplete() {
@@ -164,7 +164,7 @@
refreshJwt: '',
})
}
navigate('/dashboard')
navigate(routes.dashboard)
}
</script>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
let code = $state('')
@@ -64,7 +64,7 @@
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
+6 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
interface AccountInfo {
@@ -75,12 +75,12 @@
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
@@ -100,9 +100,9 @@
function handleDifferentAccount() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
navigate('/oauth/login')
navigate(routes.oauthLogin)
}
}
@@ -113,9 +113,7 @@
<div class="oauth-accounts-container">
{#if loading}
<div class="loading">
<p>{$_('common.loading')}</p>
</div>
<div class="loading"></div>
{:else if error}
<div class="error-container">
<h1>Error</h1>
+16 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
interface ScopeInfo {
@@ -57,15 +57,12 @@
const data: ConsentData = await response.json()
consentData = data
for (const scope of data.scopes) {
if (scope.required) {
scopeSelections[scope.scope] = true
} else if (scope.granted !== null) {
scopeSelections[scope.scope] = scope.granted
} else {
scopeSelections[scope.scope] = true
}
}
scopeSelections = Object.fromEntries(
data.scopes.map((scope) => [
scope.scope,
scope.required ? true : scope.granted ?? true,
])
)
if (!data.show_consent) {
await submitConsent()
@@ -144,14 +141,13 @@
}
function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
const groups: Record<string, ScopeInfo[]> = {}
for (const scope of scopes) {
if (!groups[scope.category]) {
groups[scope.category] = []
}
groups[scope.category].push(scope)
}
return groups
return scopes.reduce(
(groups, scope) => ({
...groups,
[scope.category]: [...(groups[scope.category] ?? []), scope],
}),
{} as Record<string, ScopeInfo[]>
)
}
$effect(() => {
@@ -163,14 +159,12 @@
<div class="consent-container">
{#if loading}
<div class="loading">
<p>{$_('common.loading')}</p>
</div>
<div class="loading"></div>
{:else if error}
<div class="error-container">
<h1>{$_('oauth.error.title')}</h1>
<div class="error">{error}</div>
<button type="button" onclick={() => navigate('/login')}>
<button type="button" onclick={() => navigate(routes.login)}>
{$_('common.backToLogin')}
</button>
</div>
+13 -49
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import {
prepareRequestOptions,
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
let delegatedDid = $state<string | null>(null)
let delegatedHandle = $state<string | null>(null)
@@ -103,37 +108,6 @@
}
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: base64UrlToArrayBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !controllerDid || !delegatedDid) {
@@ -165,9 +139,10 @@
}
const { options } = await startResponse.json()
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: prepareCredentialRequestOptions(options.publicKey)
publicKey: publicKeyOptions
}) as PublicKeyCredential | null
if (!credential) {
@@ -176,18 +151,7 @@
return
}
const assertionResponse = credential.response as AuthenticatorAssertionResponse
const credentialData = {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
signature: arrayBufferToBase64Url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
}
}
const credentialData = serializeAssertionResponse(credential)
const finishResponse = await fetch('/oauth/passkey/finish', {
method: 'POST',
@@ -213,12 +177,12 @@
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
@@ -272,12 +236,12 @@
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
+15 -51
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import {
prepareRequestOptions,
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
let username = $state('')
let password = $state('')
@@ -95,7 +100,7 @@
if (!hasPassword && !hasPasskeys && isDelegated && data.did) {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/delegation?request_uri=${encodeURIComponent(requestUri)}&delegated_did=${encodeURIComponent(data.did)}`)
navigate(routes.oauthDelegation, { params: { request_uri: requestUri, delegated_did: data.did } })
return
}
}
@@ -142,9 +147,10 @@
}
const { options } = await startResponse.json()
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: prepareCredentialRequestOptions(options.publicKey)
publicKey: publicKeyOptions
}) as PublicKeyCredential | null
if (!credential) {
@@ -153,18 +159,7 @@
return
}
const assertionResponse = credential.response as AuthenticatorAssertionResponse
const credentialData = {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
signature: arrayBufferToBase64Url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
}
}
const credentialData = serializeAssertionResponse(credential)
const finishResponse = await fetch('/oauth/passkey/finish', {
method: 'POST',
@@ -187,12 +182,12 @@
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
@@ -214,37 +209,6 @@
}
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: base64UrlToArrayBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
@@ -280,12 +244,12 @@
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
@@ -456,7 +420,7 @@
</form>
<p class="help-links">
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
<a href={getFullUrl(routes.resetPassword)}>{$_('login.forgotPassword')}</a> &middot; <a href={getFullUrl(routes.requestPasskeyRecovery)}>{$_('login.lostPasskey')}</a>
</p>
</div>
+9 -47
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import {
prepareRequestOptions,
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
let loading = $state(false)
let error = $state<string | null>(null)
@@ -13,37 +18,6 @@
const t = $_
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function startPasskeyAuth() {
const requestUri = getRequestUri()
if (!requestUri) {
@@ -75,7 +49,7 @@
}
const { options } = await startResponse.json()
const publicKeyOptions = prepareAuthOptions(options)
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
@@ -87,19 +61,7 @@
return
}
const pkCredential = credential as PublicKeyCredential
const response = pkCredential.response as AuthenticatorAssertionResponse
const credentialResponse = {
id: pkCredential.id,
type: pkCredential.type,
rawId: arrayBufferToBase64Url(pkCredential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
signature: arrayBufferToBase64Url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
},
}
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
const finishResponse = await fetch('/oauth/authorize/passkey', {
method: 'POST',
@@ -141,7 +103,7 @@
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
let code = $state('')
@@ -61,7 +61,7 @@
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
@@ -66,11 +66,11 @@
}
function goToLogin() {
navigate('/login')
navigate(routes.login)
}
function requestNewLink() {
navigate('/login')
navigate(routes.login)
}
</script>
+7 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
@@ -30,7 +30,7 @@
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate('/dashboard')
navigate(routes.dashboard)
}
})
@@ -109,7 +109,7 @@
if (flow) {
await flow.finalizeSession()
}
navigate('/dashboard')
navigate(routes.dashboard)
}
function isChannelAvailable(ch: string): boolean {
@@ -166,15 +166,14 @@
{/if}
{#if loadingServerInfo || !flow}
<p class="loading">{$_('common.loading')}</p>
<div class="loading"></div>
{:else if flow.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="/app/migrate" class="migrate-link">
<a href={getFullUrl(routes.migrate)} class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
@@ -381,10 +380,10 @@
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
</p>
<p class="link-text">
{$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a>
{$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
</p>
</div>
</div>
+7 -47
View File
@@ -9,6 +9,11 @@
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
let serverInfo = $state<{
availableUserDomains: string[]
@@ -84,41 +89,6 @@
return null
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
user: {
...options.publicKey.user,
id: base64UrlToArrayBuffer(options.publicKey.user.id)
},
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handleInfoSubmit(e: Event) {
e.preventDefault()
if (!flow) return
@@ -156,7 +126,7 @@
passkeyName || undefined
)
const publicKeyOptions = preparePublicKeyOptions(options)
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
@@ -167,17 +137,7 @@
return
}
const pkCredential = credential as PublicKeyCredential
const response = pkCredential.response as AuthenticatorAttestationResponse
const credentialResponse = {
id: pkCredential.id,
type: pkCredential.type,
rawId: arrayBufferToBase64Url(pkCredential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
attestationObject: arrayBufferToBase64Url(response.attestationObject),
},
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
const result = await api.completePasskeySetup(
flow.account.did,
+63 -33
View File
@@ -1,9 +1,22 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _, locale } from '../lib/i18n'
const auth = getAuthState()
import type { Session } from '../lib/types/api'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
type View = 'collections' | 'records' | 'record' | 'create'
let view = $state<View>('collections')
let collections = $state<string[]>([])
@@ -31,21 +44,21 @@
let saving = $state(false)
let filter = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadCollections()
}
})
async function loadCollections() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
const result = await api.describeRepo(session.accessJwt, session.did)
collections = result.collections.sort()
} catch (e) {
setError(e)
@@ -54,7 +67,7 @@
}
}
async function selectCollection(collection: string) {
if (!auth.session) return
if (!session) return
selectedCollection = collection
records = []
recordsCursor = undefined
@@ -62,7 +75,7 @@
loading = true
error = null
try {
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
const result = await api.listRecords(session.accessJwt, session.did, collection, { limit: 50 })
records = result.records.map(r => ({
...r,
rkey: r.uri.split('/').pop()!
@@ -75,10 +88,10 @@
}
}
async function loadMoreRecords() {
if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return
if (!session || !selectedCollection || !recordsCursor || loadingMore) return
loadingMore = true
try {
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
const result = await api.listRecords(session.accessJwt, session.did, selectedCollection, {
limit: 50,
cursor: recordsCursor
})
@@ -154,7 +167,7 @@
}
async function handleCreate(e: Event) {
e.preventDefault()
if (!auth.session) return
if (!session) return
const record = validateJson()
if (!record) return
if (!newCollection.trim()) {
@@ -165,8 +178,8 @@
error = null
try {
const result = await api.createRecord(
auth.session.accessJwt,
auth.session.did,
session.accessJwt,
session.did,
newCollection.trim(),
record,
newRkey.trim() || undefined
@@ -182,23 +195,23 @@
}
async function handleUpdate(e: Event) {
e.preventDefault()
if (!auth.session || !selectedRecord || !selectedCollection) return
if (!session || !selectedRecord || !selectedCollection) return
const record = validateJson()
if (!record) return
saving = true
error = null
try {
await api.putRecord(
auth.session.accessJwt,
auth.session.did,
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey,
record
)
success = $_('repoExplorer.recordUpdated')
const updated = await api.getRecord(
auth.session.accessJwt,
auth.session.did,
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey
)
@@ -211,14 +224,14 @@
}
}
async function handleDelete() {
if (!auth.session || !selectedRecord || !selectedCollection) return
if (!session || !selectedRecord || !selectedCollection) return
if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return
saving = true
error = null
try {
await api.deleteRecord(
auth.session.accessJwt,
auth.session.did,
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey
)
@@ -259,17 +272,12 @@
: records
)
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const col of cols) {
return cols.reduce((groups, col) => {
const parts = col.split('.')
const authority = parts.slice(0, -1).join('.')
const name = parts[parts.length - 1]
if (!groups.has(authority)) {
groups.set(authority, [])
}
groups.get(authority)!.push(name)
}
return groups
return groups.set(authority, [...(groups.get(authority) ?? []), name])
}, new Map<string, string[]>())
}
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
</script>
@@ -303,8 +311,8 @@
{$_('repoExplorer.createRecord')}
{/if}
</h1>
{#if auth.session}
<p class="did">{auth.session.did}</p>
{#if session}
<p class="did">{session.did}</p>
{/if}
</header>
{#if error}
@@ -319,7 +327,11 @@
<div class="message success">{success}</div>
{/if}
{#if loading}
<p class="loading-text">{$_('common.loading')}</p>
<div class="skeleton-list">
{#each Array(4) as _}
<div class="skeleton-row"></div>
{/each}
</div>
{:else if view === 'collections'}
<div class="toolbar">
<input
@@ -980,4 +992,22 @@
background: var(--accent);
color: var(--text-inverse);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.skeleton-row {
height: 44px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
@@ -36,7 +36,7 @@
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
<button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button>
<button onclick={() => navigate(routes.login)}>{$_('common.backToLogin')}</button>
</div>
{:else}
<h1>{$_('requestPasskeyRecovery.title')}</h1>
@@ -71,7 +71,7 @@
{/if}
<p class="link-text">
<a href="/app/login">{$_('common.backToLogin')}</a>
<a href={getFullUrl(routes.login)}>{$_('common.backToLogin')}</a>
</p>
</div>
+12 -5
View File
@@ -1,10 +1,17 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { getAuthState } from '../lib/auth.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
const session = $derived(getSession())
let email = $state('')
let token = $state('')
@@ -16,8 +23,8 @@
let tokenSent = $state(false)
$effect(() => {
if (auth.session) {
navigate('/dashboard')
if (session) {
navigate(routes.dashboard)
}
})
@@ -55,7 +62,7 @@
try {
await api.resetPassword(token, newPassword)
success = $_('resetPassword.success')
setTimeout(() => navigate('/login'), 2000)
setTimeout(() => navigate(routes.login), 2000)
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to reset password'
} finally {
+113 -127
View File
@@ -1,13 +1,31 @@
<script lang="ts">
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import ReauthModal from '../components/ReauthModal.svelte'
import { _ } from '../lib/i18n'
import { formatDate as formatDateUtil } from '../lib/date'
import type { Session } from '../lib/types/api'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
const auth = getAuthState()
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let loading = $state(true)
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
@@ -56,13 +74,13 @@
let pendingAction = $state<(() => Promise<void>) | null>(null)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadTotpStatus()
loadPasskeys()
loadPasswordStatus()
@@ -71,10 +89,10 @@
})
async function loadPasswordStatus() {
if (!auth.session) return
if (!session) return
passwordLoading = true
try {
const status = await api.getPasswordStatus(auth.session.accessJwt)
const status = await api.getPasswordStatus(session.accessJwt)
hasPassword = status.hasPassword
} catch {
hasPassword = true
@@ -84,10 +102,10 @@
}
async function loadLegacyLoginPreference() {
if (!auth.session) return
if (!session) return
legacyLoginLoading = true
try {
const pref = await api.getLegacyLoginPreference(auth.session.accessJwt)
const pref = await api.getLegacyLoginPreference(session.accessJwt)
allowLegacyLogin = pref.allowLegacyLogin
hasMfa = pref.hasMfa
} catch {
@@ -99,12 +117,12 @@
}
async function handleToggleLegacyLogin() {
if (!auth.session) return
if (!session) return
legacyLoginUpdating = true
try {
const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin)
const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
allowLegacyLogin = result.allowLegacyLogin
showMessage('success', allowLegacyLogin
toast.success(allowLegacyLogin
? $_('security.legacyLoginEnabled')
: $_('security.legacyLoginDisabled'))
} catch (e) {
@@ -114,10 +132,10 @@
pendingAction = handleToggleLegacyLogin
showReauthModal = true
} else {
showMessage('error', e.message)
toast.error(e.message)
}
} else {
showMessage('error', $_('security.failedToUpdatePreference'))
toast.error($_('security.failedToUpdatePreference'))
}
} finally {
legacyLoginUpdating = false
@@ -125,18 +143,18 @@
}
async function handleRemovePassword() {
if (!auth.session) return
if (!session) return
removePasswordLoading = true
try {
const token = await getValidToken()
if (!token) {
showMessage('error', $_('security.sessionExpired'))
toast.error($_('security.sessionExpired'))
return
}
await api.removePassword(token)
hasPassword = false
showRemovePasswordForm = false
showMessage('success', $_('security.passwordRemoved'))
toast.success($_('security.passwordRemoved'))
} catch (e) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired') {
@@ -144,10 +162,10 @@
pendingAction = handleRemovePassword
showReauthModal = true
} else {
showMessage('error', e.message)
toast.error(e.message)
}
} else {
showMessage('error', $_('security.failedToRemovePassword'))
toast.error($_('security.failedToRemovePassword'))
}
} finally {
removePasswordLoading = false
@@ -166,36 +184,29 @@
}
async function loadTotpStatus() {
if (!auth.session) return
if (!session) return
loading = true
try {
const status = await api.getTotpStatus(auth.session.accessJwt)
const status = await api.getTotpStatus(session.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
} catch {
showMessage('error', $_('security.failedToLoadTotpStatus'))
toast.error($_('security.failedToLoadTotpStatus'))
} finally {
loading = false
}
}
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleStartSetup() {
if (!auth.session) return
if (!session) return
verifyLoading = true
try {
const result = await api.createTotpSecret(auth.session.accessJwt)
const result = await api.createTotpSecret(session.accessJwt)
qrBase64 = result.qrBase64
totpUri = result.uri
setupStep = 'qr'
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
verifyLoading = false
}
@@ -203,17 +214,17 @@
async function handleVerifySetup(e: Event) {
e.preventDefault()
if (!auth.session || !verifyCode) return
if (!session || !verifyCode) return
verifyLoading = true
try {
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
const result = await api.enableTotp(session.accessJwt, verifyCode)
backupCodes = result.backupCodes
setupStep = 'backup'
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
toast.error(e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
} finally {
verifyLoading = false
}
@@ -224,23 +235,23 @@
backupCodes = []
qrBase64 = ''
totpUri = ''
showMessage('success', $_('security.totpEnabledSuccess'))
toast.success($_('security.totpEnabledSuccess'))
}
async function handleDisable(e: Event) {
e.preventDefault()
if (!auth.session || !disablePassword || !disableCode) return
if (!session || !disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
disablePassword = ''
disableCode = ''
showMessage('success', $_('security.totpDisabledSuccess'))
toast.success($_('security.totpDisabledSuccess'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
toast.error(e instanceof ApiError ? e.message : 'Failed to disable TOTP')
} finally {
disableLoading = false
}
@@ -248,17 +259,17 @@
async function handleRegenerate(e: Event) {
e.preventDefault()
if (!auth.session || !regenPassword || !regenCode) return
if (!session || !regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
backupCodes = result.backupCodes
setupStep = 'backup'
showRegenForm = false
regenPassword = ''
regenCode = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
toast.error(e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
} finally {
regenLoading = false
}
@@ -267,57 +278,49 @@
function copyBackupCodes() {
const text = backupCodes.join('\n')
navigator.clipboard.writeText(text)
showMessage('success', $_('security.backupCodesCopied'))
toast.success($_('security.backupCodesCopied'))
}
async function loadPasskeys() {
if (!auth.session) return
if (!session) return
passkeysLoading = true
try {
const result = await api.listPasskeys(auth.session.accessJwt)
const result = await api.listPasskeys(session.accessJwt)
passkeys = result.passkeys
} catch {
showMessage('error', $_('security.failedToLoadPasskeys'))
toast.error($_('security.failedToLoadPasskeys'))
} finally {
passkeysLoading = false
}
}
async function handleAddPasskey() {
if (!auth.session) return
if (!session) return
if (!window.PublicKeyCredential) {
showMessage('error', $_('security.passkeysNotSupported'))
toast.error($_('security.passkeysNotSupported'))
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = preparePublicKeyOptions(options)
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
if (!credential) {
showMessage('error', $_('security.passkeyCreationCancelled'))
toast.error($_('security.passkeyCreationCancelled'))
return
}
const credentialResponse = {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId),
response: {
clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON),
attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject),
},
}
await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined)
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
showMessage('success', $_('security.passkeyAddedSuccess'))
toast.success($_('security.passkeyAddedSuccess'))
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
showMessage('error', $_('security.passkeyCreationCancelled'))
toast.error($_('security.passkeyCreationCancelled'))
} else {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
}
} finally {
addingPasskey = false
@@ -325,29 +328,29 @@
}
async function handleDeletePasskey(id: string) {
if (!auth.session) return
if (!session) return
const passkey = passkeys.find(p => p.id === id)
const name = passkey?.friendlyName || 'this passkey'
if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
try {
await api.deletePasskey(auth.session.accessJwt, id)
await api.deletePasskey(session.accessJwt, id)
await loadPasskeys()
showMessage('success', $_('security.passkeyDeleted'))
toast.success($_('security.passkeyDeleted'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
}
}
async function handleSavePasskeyName() {
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
showMessage('success', $_('security.passkeyRenamed'))
toast.success($_('security.passkeyRenamed'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
}
}
@@ -361,41 +364,6 @@
editPasskeyName = ''
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
user: {
...options.publicKey.user,
id: base64UrlToArrayBuffer(options.publicKey.user.id)
},
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
function formatDate(dateStr: string): string {
return formatDateUtil(dateStr)
}
@@ -403,16 +371,16 @@
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('security.title')}</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
{#if loading}
<div class="loading">{$_('common.loading')}</div>
<div class="skeleton-grid">
{#each Array(4) as _}
<div class="skeleton-section"></div>
{/each}
</div>
{:else}
<div class="sections-grid">
<section>
@@ -594,9 +562,7 @@
{$_('security.passkeysDescription')}
</p>
{#if passkeysLoading}
<div class="loading">{$_('security.loadingPasskeys')}</div>
{:else}
{#if !passkeysLoading}
{#if passkeys.length > 0}
<div class="passkey-list">
{#each passkeys as passkey}
@@ -668,9 +634,7 @@
{$_('security.passwordDescription')}
</p>
{#if passwordLoading}
<div class="loading">{$_('common.loading')}</div>
{:else if hasPassword}
{#if !passwordLoading && hasPassword}
<div class="status enabled">
<span>{$_('security.passwordStatus')}</span>
</div>
@@ -722,7 +686,7 @@
<p class="description">
{$_('security.trustedDevicesDescription')}
</p>
<a href="/app/trusted-devices" class="section-link">
<a href={getFullUrl(routes.trustedDevices)} class="section-link">
{$_('security.manageTrustedDevices')} &rarr;
</a>
</section>
@@ -735,9 +699,7 @@
{$_('security.legacyLoginDescription')}
</p>
{#if legacyLoginLoading}
<div class="loading">{$_('common.loading')}</div>
{:else}
{#if !legacyLoginLoading}
<div class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">{$_('security.legacyLogin')}</span>
@@ -765,8 +727,8 @@
<strong>{$_('security.legacyLoginWarning')}</strong>
<p>{$_('security.totpPasswordWarning')}</p>
<ol>
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="/app/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="/app/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
</ol>
</div>
{/if}
@@ -1222,4 +1184,28 @@
.warning-box a {
color: var(--accent);
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6);
}
.skeleton-section {
height: 200px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@media (max-width: 900px) {
.skeleton-grid {
grid-template-columns: 1fr;
}
}
</style>
+51 -24
View File
@@ -1,12 +1,25 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
const auth = getAuthState()
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let error = $state<string | null>(null)
let sessions = $state<Array<{
id: string
sessionType: string
@@ -16,58 +29,59 @@
isCurrent: boolean
}>>([])
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadSessions()
}
})
async function loadSessions() {
if (!auth.session) return
if (!session) return
loading = true
error = null
try {
const result = await api.listSessions(auth.session.accessJwt)
const result = await api.listSessions(session.accessJwt)
sessions = result.sessions
} catch (e) {
error = e instanceof ApiError ? e.message : $_('sessions.failedToLoad')
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToLoad'))
} finally {
loading = false
}
}
async function revokeSession(sessionId: string, isCurrent: boolean) {
if (!auth.session) return
if (!session) return
const msg = isCurrent
? $_('sessions.revokeCurrentConfirm')
: $_('sessions.revokeConfirm')
if (!confirm(msg)) return
try {
await api.revokeSession(auth.session.accessJwt, sessionId)
await api.revokeSession(session.accessJwt, sessionId)
if (isCurrent) {
navigate('/login')
navigate(routes.login)
} else {
sessions = sessions.filter(s => s.id !== sessionId)
toast.success($_('sessions.sessionRevoked'))
}
} catch (e) {
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevoke')
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevoke'))
}
}
async function revokeAllSessions() {
if (!auth.session) return
if (!session) return
const otherSessions = sessions.filter(s => !s.isCurrent)
if (otherSessions.length === 0) {
error = $_('sessions.noOtherSessions')
toast.warning($_('sessions.noOtherSessions'))
return
}
if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return
try {
await api.revokeAllSessions(auth.session.accessJwt)
await api.revokeAllSessions(session.accessJwt)
sessions = sessions.filter(s => s.isCurrent)
toast.success($_('sessions.allSessionsRevoked'))
} catch (e) {
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll')
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll'))
}
}
function formatDate(dateStr: string): string {
@@ -88,15 +102,16 @@
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('sessions.title')}</h1>
</header>
{#if loading}
<p class="loading">{$_('sessions.loadingSessions')}</p>
<div class="sessions-list">
{#each Array(3) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
{#if sessions.length === 0}
<p class="empty">{$_('sessions.noSessions')}</p>
{:else}
@@ -172,13 +187,25 @@
margin: var(--space-2) 0 0 0;
}
.loading,
.empty {
text-align: center;
color: var(--text-secondary);
padding: var(--space-7);
}
.skeleton-card {
height: 80px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.sessions-list {
display: flex;
flex-direction: column;
+100 -97
View File
@@ -1,13 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
const auth = getAuthState()
import { isOk } from '../lib/types/result'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
const auth = $derived(getAuthState())
const supportedLocales = getSupportedLocales()
let pdsHostname = $state<string | null>(null)
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const loading = $derived(isLoading())
onMount(() => {
api.describeServer().then(info => {
if (info.availableUserDomains?.length) {
@@ -15,20 +30,21 @@
}
}).catch(() => {})
})
let localeLoading = $state(false)
async function handleLocaleChange(newLocale: SupportedLocale) {
if (!auth.session) return
if (!session) return
setLocale(newLocale)
localeLoading = true
try {
await api.updateLocale(auth.session.accessJwt, newLocale)
await api.updateLocale(session.accessJwt, newLocale)
} catch (e) {
console.error('Failed to save locale preference:', e)
} finally {
localeLoading = false
}
}
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let emailLoading = $state(false)
let newEmail = $state('')
let emailToken = $state('')
@@ -46,112 +62,107 @@
let newPassword = $state('')
let confirmNewPassword = $state('')
let showBYOHandle = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!loading && !session) {
navigate(routes.login)
}
})
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleRequestEmailUpdate() {
if (!auth.session) return
if (!session) return
emailLoading = true
message = null
try {
const result = await api.requestEmailUpdate(auth.session.accessJwt)
const result = await api.requestEmailUpdate(session.accessJwt)
emailTokenRequired = result.tokenRequired
if (emailTokenRequired) {
showMessage('success', $_('settings.messages.emailCodeSentToCurrent'))
toast.success($_('settings.messages.emailCodeSentToCurrent'))
} else {
emailTokenRequired = true
}
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
} finally {
emailLoading = false
}
}
async function handleConfirmEmailUpdate(e: Event) {
e.preventDefault()
if (!auth.session || !newEmail || !emailToken) return
if (!session || !newEmail || !emailToken) return
emailLoading = true
message = null
try {
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
await api.updateEmail(session.accessJwt, newEmail, emailToken)
await refreshSession()
showMessage('success', $_('settings.messages.emailUpdated'))
toast.success($_('settings.messages.emailUpdated'))
newEmail = ''
emailToken = ''
emailTokenRequired = false
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
} finally {
emailLoading = false
}
}
async function handleUpdateHandle(e: Event) {
e.preventDefault()
if (!auth.session || !newHandle) return
if (!session || !newHandle) return
handleLoading = true
message = null
try {
const fullHandle = showBYOHandle
? newHandle
: `${newHandle}.${pdsHostname}`
await api.updateHandle(auth.session.accessJwt, fullHandle)
await api.updateHandle(session.accessJwt, fullHandle)
await refreshSession()
showMessage('success', $_('settings.messages.handleUpdated'))
toast.success($_('settings.messages.handleUpdated'))
newHandle = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
} finally {
handleLoading = false
}
}
async function handleRequestDelete() {
if (!auth.session) return
if (!session) return
deleteLoading = true
message = null
try {
await api.requestAccountDelete(auth.session.accessJwt)
await api.requestAccountDelete(session.accessJwt)
deleteTokenSent = true
showMessage('success', $_('settings.messages.deletionConfirmationSent'))
toast.success($_('settings.messages.deletionConfirmationSent'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
} finally {
deleteLoading = false
}
}
async function handleConfirmDelete(e: Event) {
e.preventDefault()
if (!auth.session || !deletePassword || !deleteToken) return
if (!session || !deletePassword || !deleteToken) return
if (!confirm($_('settings.messages.deleteConfirmation'))) {
return
}
deleteLoading = true
message = null
try {
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
await api.deleteAccount(session.did, deletePassword, deleteToken)
await logout()
navigate('/login')
navigate(routes.login)
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
} finally {
deleteLoading = false
}
}
async function handleExportRepo() {
if (!auth.session) return
if (!session) return
exportLoading = true
message = null
try {
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(session.did)}`, {
headers: {
'Authorization': `Bearer ${auth.session.accessJwt}`
'Authorization': `Bearer ${session.accessJwt}`
}
})
if (!response.ok) {
@@ -162,26 +173,26 @@
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${auth.session.handle}-repo.car`
a.download = `${session.handle}-repo.car`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showMessage('success', $_('settings.messages.repoExported'))
toast.success($_('settings.messages.repoExported'))
} catch (e) {
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
} finally {
exportLoading = false
}
}
async function handleExportBlobs() {
if (!auth.session) return
if (!session) return
exportBlobsLoading = true
message = null
try {
const response = await fetch('/xrpc/_backup.exportBlobs', {
headers: {
'Authorization': `Bearer ${auth.session.accessJwt}`
'Authorization': `Bearer ${session.accessJwt}`
}
})
if (!response.ok) {
@@ -190,20 +201,20 @@
}
const blob = await response.blob()
if (blob.size === 0) {
showMessage('success', $_('settings.messages.noBlobsToExport'))
toast.success($_('settings.messages.noBlobsToExport'))
return
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${auth.session.handle}-blobs.zip`
a.download = `${session.handle}-blobs.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showMessage('success', $_('settings.messages.blobsExported'))
toast.success($_('settings.messages.blobsExported'))
} catch (e) {
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
} finally {
exportBlobsLoading = false
}
@@ -225,10 +236,10 @@
let restoreLoading = $state(false)
async function loadBackups() {
if (!auth.session) return
if (!session) return
backupsLoading = true
try {
const result = await api.listBackups(auth.session.accessJwt)
const result = await api.listBackups(session.accessJwt)
backups = result.backups
backupEnabled = result.backupEnabled
} catch (e) {
@@ -243,60 +254,59 @@
})
async function handleToggleBackup() {
if (!auth.session) return
if (!session) return
const newEnabled = !backupEnabled
backupsLoading = true
try {
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
await api.setBackupEnabled(session.accessJwt, newEnabled)
backupEnabled = newEnabled
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
toast.success(newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
} finally {
backupsLoading = false
}
}
async function handleCreateBackup() {
if (!auth.session) return
if (!session) return
createBackupLoading = true
message = null
try {
await api.createBackup(auth.session.accessJwt)
await api.createBackup(session.accessJwt)
await loadBackups()
showMessage('success', $_('settings.backups.created'))
toast.success($_('settings.backups.created'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
} finally {
createBackupLoading = false
}
}
async function handleDownloadBackup(id: string, rev: string) {
if (!auth.session) return
if (!session) return
try {
const blob = await api.getBackup(auth.session.accessJwt, id)
const blob = await api.getBackup(session.accessJwt, id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${auth.session.handle}-${rev}.car`
a.download = `${session.handle}-${rev}.car`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
}
}
async function handleDeleteBackup(id: string) {
if (!auth.session) return
if (!session) return
try {
await api.deleteBackup(auth.session.accessJwt, id)
await api.deleteBackup(session.accessJwt, id)
await loadBackups()
showMessage('success', $_('settings.backups.deleted'))
toast.success($_('settings.backups.deleted'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
}
}
@@ -308,17 +318,16 @@
}
async function handleRestore() {
if (!auth.session || !restoreFile) return
if (!session || !restoreFile) return
restoreLoading = true
message = null
try {
const buffer = await restoreFile.arrayBuffer()
const car = new Uint8Array(buffer)
await api.importRepo(auth.session.accessJwt, car)
showMessage('success', $_('settings.backups.restored'))
await api.importRepo(session.accessJwt, car)
toast.success($_('settings.backups.restored'))
restoreFile = null
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
} finally {
restoreLoading = false
}
@@ -342,25 +351,24 @@
async function handleChangePassword(e: Event) {
e.preventDefault()
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
if (!session || !currentPassword || !newPassword || !confirmNewPassword) return
if (newPassword !== confirmNewPassword) {
showMessage('error', $_('settings.messages.passwordsDoNotMatch'))
toast.error($_('settings.messages.passwordsDoNotMatch'))
return
}
if (newPassword.length < 8) {
showMessage('error', $_('settings.messages.passwordTooShort'))
toast.error($_('settings.messages.passwordTooShort'))
return
}
passwordLoading = true
message = null
try {
await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
showMessage('success', $_('settings.messages.passwordChanged'))
await api.changePassword(session.accessJwt, currentPassword, newPassword)
toast.success($_('settings.messages.passwordChanged'))
currentPassword = ''
newPassword = ''
confirmNewPassword = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
} finally {
passwordLoading = false
}
@@ -368,12 +376,9 @@
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('settings.title')}</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
<div class="sections-grid">
<section>
<h2>{$_('settings.language')}</h2>
@@ -391,8 +396,8 @@
</section>
<section>
<h2>{$_('settings.changeEmail')}</h2>
{#if auth.session?.email}
<p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p>
{#if session?.email}
<p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
{/if}
{#if emailTokenRequired}
<form onsubmit={handleConfirmEmailUpdate}>
@@ -435,8 +440,8 @@
</section>
<section>
<h2>{$_('settings.changeHandle')}</h2>
{#if auth.session}
<p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p>
{#if session}
<p class="current">{$_('settings.currentHandle', { values: { handle: session.handle } })}</p>
{/if}
<div class="tabs">
<button
@@ -459,21 +464,21 @@
{#if showBYOHandle}
<div class="byo-handle">
<p class="description">{$_('settings.customDomainDescription')}</p>
{#if auth.session}
{#if session}
<div class="verification-info">
<h3>{$_('settings.setupInstructions')}</h3>
<p>{$_('settings.setupMethodsIntro')}</p>
<div class="method">
<h4>{$_('settings.dnsMethod')}</h4>
<p>{$_('settings.dnsMethodDesc')}</p>
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={session.did}"</code>
</div>
<div class="method">
<h4>{$_('settings.httpMethod')}</h4>
<p>{$_('settings.httpMethodDesc')}</p>
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
<p>{$_('settings.httpMethodContent')}</p>
<code class="record">{auth.session.did}</code>
<code class="record">{session.did}</code>
</div>
</div>
{/if}
@@ -579,9 +584,7 @@
<span>{$_('settings.backups.enableAutomatic')}</span>
</label>
{#if backupsLoading}
<p class="loading">{$_('common.loading')}</p>
{:else if backups.length > 0}
{#if !backupsLoading && backups.length > 0}
<ul class="backup-list">
{#each backups as backup}
<li class="backup-item">
+54 -30
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
interface TrustedDevice {
id: string
@@ -14,54 +16,57 @@
lastSeenAt: string
}
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let devices = $state<TrustedDevice[]>([])
let loading = $state(true)
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let editingDeviceId = $state<string | null>(null)
let editDeviceName = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (auth.session) {
if (session) {
loadDevices()
}
})
async function loadDevices() {
if (!auth.session) return
if (!session) return
loading = true
try {
const result = await api.listTrustedDevices(auth.session.accessJwt)
const result = await api.listTrustedDevices(session.accessJwt)
devices = result.devices
} catch {
showMessage('error', $_('trustedDevices.failedToLoad'))
toast.error($_('trustedDevices.failedToLoad'))
} finally {
loading = false
}
}
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleRevoke(deviceId: string) {
if (!auth.session) return
if (!session) return
if (!confirm($_('trustedDevices.revokeConfirm'))) return
try {
await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
await api.revokeTrustedDevice(session.accessJwt, deviceId)
await loadDevices()
showMessage('success', $_('trustedDevices.deviceRevoked'))
toast.success($_('trustedDevices.deviceRevoked'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
}
}
@@ -76,15 +81,15 @@
}
async function handleSaveDeviceName() {
if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
if (!session || !editingDeviceId || !editDeviceName.trim()) return
try {
await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
await api.updateTrustedDevice(session.accessJwt, editingDeviceId, editDeviceName.trim())
await loadDevices()
editingDeviceId = null
editDeviceName = ''
showMessage('success', $_('trustedDevices.deviceRenamed'))
toast.success($_('trustedDevices.deviceRenamed'))
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
}
}
@@ -112,14 +117,10 @@
<div class="page">
<header>
<a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
<a href={getFullUrl(routes.security)} class="back">{$_('trustedDevices.backToSecurity')}</a>
<h1>{$_('trustedDevices.title')}</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
<div class="description">
<p>
{$_('trustedDevices.description')}
@@ -127,7 +128,11 @@
</div>
{#if loading}
<div class="loading">{$_('common.loading')}</div>
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else if devices.length === 0}
<div class="empty-state">
<p>{$_('trustedDevices.noDevices')}</p>
@@ -379,4 +384,23 @@
.btn-danger:hover {
background: var(--error-bg);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.skeleton-card {
height: 100px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+20 -19
View File
@@ -2,8 +2,9 @@
import { onMount } from 'svelte'
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
const STORAGE_KEY = 'tranquil_pds_pending_verification'
@@ -29,16 +30,16 @@
let successPurpose = $state<string | null>(null)
let successChannel = $state<string | null>(null)
const auth = getAuthState()
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function parseQueryParams() {
const params: Record<string, string> = {}
const searchParams = new URLSearchParams(window.location.search)
for (const [key, value] of searchParams.entries()) {
params[key] = value
}
return params
const session = $derived(getSession())
function parseQueryParams(): Record<string, string> {
return Object.fromEntries(new URLSearchParams(window.location.search))
}
onMount(async () => {
@@ -74,9 +75,9 @@
})
$effect(() => {
if (mode === 'signup' && auth.session) {
if (mode === 'signup' && session) {
clearPendingVerification()
navigate('/dashboard')
navigate(routes.dashboard)
}
})
@@ -96,8 +97,8 @@
await confirmSignup(pendingVerification.did, verificationCode.trim())
clearPendingVerification()
navigate('/dashboard')
} catch (e: any) {
error = e.message || 'Verification failed'
} catch (e) {
error = e instanceof Error ? e.message : 'Verification failed'
} finally {
submitting = false
}
@@ -118,7 +119,7 @@
success = true
successPurpose = result.purpose
successChannel = result.channel
} catch (e: any) {
} catch (e) {
if (e instanceof ApiError) {
if (e.error === 'AuthenticationRequired') {
error = 'You must be signed in to complete this verification. Please sign in and try again.'
@@ -149,7 +150,7 @@
success = true
successPurpose = 'email-update'
successChannel = 'email'
} catch (e: any) {
} catch (e) {
if (e instanceof ApiError) {
error = e.message
} else {
@@ -171,8 +172,8 @@
try {
await resendVerification(pendingVerification.did)
resendMessage = $_('verify.codeResent')
} catch (e: any) {
error = e.message || 'Failed to resend code'
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to resend code'
} finally {
resendingCode = false
}
@@ -186,8 +187,8 @@
try {
await api.resendMigrationVerification(identifier.trim())
resendMessage = $_('verify.codeResentDetail')
} catch (e: any) {
error = e.message || 'Failed to resend verification'
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to resend verification'
} finally {
resendingCode = false
}
+2 -2
View File
@@ -128,6 +128,8 @@ impl ApiError {
| Self::AccountTakedown
| Self::InvalidCode(_)
| Self::InvalidPassword(_)
| Self::InvalidToken(_)
| Self::ExpiredToken(_)
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
Self::Forbidden
| Self::AdminRequired
@@ -196,8 +198,6 @@ impl ApiError {
| Self::InvalidVerificationChannel
| Self::SelfHostedDidWebDisabled
| Self::AccountAlreadyExists
| Self::InvalidToken(_)
| Self::ExpiredToken(_)
| Self::TokenRequired => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
}