mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 17:24:15 +00:00
More functional and typesafe frontend
This commit is contained in:
Generated
+228
-434
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+425
-236
@@ -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 };
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './result'
|
||||
export * from './branded'
|
||||
export * from './exhaustive'
|
||||
export * from './api'
|
||||
export * from './routes'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './option'
|
||||
export * from './array'
|
||||
export * from './async'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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}` }
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
<a href={getFullUrl(routes.resetPassword)}>{$_('login.forgotPassword')}</a> · <a href={getFullUrl(routes.requestPasskeyRecovery)}>{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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')} →
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user