fix: make frontend more type-safe

This commit is contained in:
lewis
2026-01-26 20:31:20 +00:00
committed by Tangled
parent d7b96773fd
commit c19904f6d6
23 changed files with 1669 additions and 886 deletions
@@ -0,0 +1,59 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes } from '../lib/router.svelte'
import type { Snippet } from 'svelte'
import type { Session } from '../lib/types/api'
import { createAuthenticatedClient, type AuthenticatedClient } from '../lib/authenticated-client'
interface Props {
children: Snippet<[{ session: Session; client: AuthenticatedClient }]>
requireAdmin?: boolean
onReady?: (session: Session, client: AuthenticatedClient) => void
}
let { children, requireAdmin = false, onReady }: Props = $props()
const auth = $derived(getAuthState())
let readyCalled = $state(false)
$effect(() => {
if (auth.kind === 'unauthenticated' || auth.kind === 'error') {
navigate(routes.login)
}
if (requireAdmin && auth.kind === 'authenticated' && !auth.session.isAdmin) {
navigate(routes.dashboard)
}
if (auth.kind === 'authenticated' && onReady && !readyCalled) {
readyCalled = true
onReady(auth.session, createAuthenticatedClient(auth.session))
}
})
</script>
{#if auth.kind === 'authenticated'}
{@render children({ session: auth.session, client: createAuthenticatedClient(auth.session) })}
{:else}
<div class="loading-container"><div class="loading-spinner"></div></div>
{/if}
<style>
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
padding: var(--space-7);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+225 -27
View File
@@ -7,6 +7,7 @@ import type {
Nsid,
RefreshToken,
Rkey,
ScopeSet,
} from "./types/branded.ts";
import {
unsafeAsAccessToken,
@@ -15,6 +16,7 @@ import {
unsafeAsHandle,
unsafeAsISODate,
unsafeAsRefreshToken,
unsafeAsScopeSet,
} from "./types/branded.ts";
import {
createDPoPProofForRequest,
@@ -23,15 +25,21 @@ import {
} from "./oauth.ts";
import type {
AccountInfo,
AccountState,
ApiErrorCode,
AppPassword,
CompletePasskeySetupResponse,
ConfirmSignupResult,
ContactState,
CreateAccountParams,
CreateAccountResult,
CreateBackupResponse,
CreatedAppPassword,
CreateRecordResponse,
DelegationAuditEntry,
DelegationControlledAccount,
DelegationController,
DelegationScopePreset,
DidDocument,
DidType,
EmailUpdateResponse,
@@ -65,6 +73,7 @@ import type {
ServerStats,
Session,
SetBackupEnabledResponse,
SsoLinkedAccount,
StartPasskeyRegistrationResponse,
SuccessResponse,
TotpSecret,
@@ -241,32 +250,119 @@ export interface VerificationMethod {
}
export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session };
export type {
ConfirmSignupResult,
CreateAccountParams,
CreateAccountResult,
DidType,
VerificationChannel,
};
export type { DidType, VerificationChannel };
function castSession(raw: unknown): Session {
function buildContactState(s: Record<string, unknown>): ContactState {
const preferredChannel = s.preferredChannel as VerificationChannel | undefined;
const email = s.email ? unsafeAsEmail(s.email as string) : undefined;
if (preferredChannel) {
return {
contactKind: "channel",
preferredChannel,
preferredChannelVerified: Boolean(s.preferredChannelVerified),
email,
};
}
if (email) {
return {
contactKind: "email",
email,
emailConfirmed: Boolean(s.emailConfirmed),
};
}
return { contactKind: "none" };
}
function buildAccountState(s: Record<string, unknown>): AccountState {
const status = s.status as string | undefined;
const isAdmin = Boolean(s.isAdmin);
const active = s.active as boolean | undefined;
if (status === "migrated") {
return {
accountKind: "migrated",
migratedToPds: (s.migratedToPds as string) || "",
migratedAt: s.migratedAt
? unsafeAsISODate(s.migratedAt as string)
: unsafeAsISODate(new Date().toISOString()),
isAdmin,
};
}
if (status === "deactivated" || active === false) {
return { accountKind: "deactivated", isAdmin };
}
if (status === "suspended") {
return { accountKind: "suspended", isAdmin };
}
return { accountKind: "active", isAdmin };
}
export function castSession(raw: unknown): Session {
const s = raw as Record<string, unknown>;
const contact = buildContactState(s);
const account = buildAccountState(s);
return {
did: unsafeAsDid(s.did as string),
handle: unsafeAsHandle(s.handle as string),
email: s.email ? unsafeAsEmail(s.email as string) : undefined,
emailConfirmed: s.emailConfirmed as boolean | undefined,
preferredChannel: s.preferredChannel as VerificationChannel | undefined,
preferredChannelVerified: s.preferredChannelVerified as boolean | undefined,
isAdmin: s.isAdmin as boolean | undefined,
active: s.active as boolean | undefined,
status: s.status as Session["status"],
migratedToPds: s.migratedToPds as string | undefined,
migratedAt: s.migratedAt
? unsafeAsISODate(s.migratedAt as string)
: undefined,
accessJwt: unsafeAsAccessToken(s.accessJwt as string),
refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string),
preferredLocale: s.preferredLocale as string | null | undefined,
...contact,
...account,
};
}
function castDelegationController(raw: unknown): DelegationController {
const c = raw as Record<string, unknown>;
return {
did: unsafeAsDid(c.did as string),
granted_scopes: unsafeAsScopeSet(c.granted_scopes as string),
added_at: unsafeAsISODate(c.added_at as string),
};
}
function castDelegationControlledAccount(
raw: unknown,
): DelegationControlledAccount {
const a = raw as Record<string, unknown>;
return {
did: unsafeAsDid(a.did as string),
handle: unsafeAsHandle(a.handle as string),
granted_scopes: unsafeAsScopeSet(a.granted_scopes as string),
};
}
function castDelegationAuditEntry(raw: unknown): DelegationAuditEntry {
const e = raw as Record<string, unknown>;
return {
id: e.id as string,
action: e.action as string,
actor_did: unsafeAsDid(e.actor_did as string),
target_did: e.target_did ? unsafeAsDid(e.target_did as string) : undefined,
details: e.details as string | undefined,
created_at: unsafeAsISODate(e.created_at as string),
};
}
function castSsoLinkedAccount(raw: unknown): SsoLinkedAccount {
const a = raw as Record<string, unknown>;
return {
id: a.id as string,
provider: a.provider as string,
provider_name: a.provider_name as string,
provider_username: a.provider_username as string,
provider_email: a.provider_email as string | undefined,
created_at: unsafeAsISODate(a.created_at as string),
last_login_at: a.last_login_at
? unsafeAsISODate(a.last_login_at as string)
: undefined,
};
}
@@ -1142,7 +1238,9 @@ export const api = {
},
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
encodeURIComponent(did)
}`;
const res = await authenticatedFetch(url, { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
@@ -1198,12 +1296,15 @@ export const api = {
},
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, {
method: "POST",
token,
headers: { "Content-Type": "application/vnd.ipld.car" },
body: car as unknown as BodyInit,
});
const res = await authenticatedFetch(
`${API_BASE}/com.atproto.repo.importRepo`,
{
method: "POST",
token,
headers: { "Content-Type": "application/vnd.ipld.car" },
body: car as unknown as BodyInit,
},
);
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
@@ -1213,7 +1314,9 @@ export const api = {
}
},
async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> {
async establishOAuthSession(
token: AccessToken,
): Promise<{ success: boolean; device_id: string }> {
const res = await authenticatedFetch("/oauth/establish-session", {
method: "POST",
token,
@@ -1228,6 +1331,101 @@ export const api = {
}
return res.json();
},
async getSsoLinkedAccounts(
token: AccessToken,
): Promise<{ accounts: SsoLinkedAccount[] }> {
const res = await authenticatedFetch("/oauth/sso/linked", { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
throw new ApiError(res.status, errData.error, errData.message);
}
return res.json();
},
listDelegationControllers(
token: AccessToken,
): Promise<Result<{ controllers: DelegationController[] }, ApiError>> {
return xrpcResult("_delegation.listControllers", { token });
},
listDelegationControlledAccounts(
token: AccessToken,
): Promise<Result<{ accounts: DelegationControlledAccount[] }, ApiError>> {
return xrpcResult("_delegation.listControlledAccounts", { token });
},
getDelegationScopePresets(): Promise<
Result<{ presets: DelegationScopePreset[] }, ApiError>
> {
return xrpcResult("_delegation.getScopePresets");
},
addDelegationController(
token: AccessToken,
controllerDid: Did,
grantedScopes: ScopeSet,
): Promise<Result<{ success: boolean }, ApiError>> {
return xrpcResult("_delegation.addController", {
method: "POST",
token,
body: { controller_did: controllerDid, granted_scopes: grantedScopes },
});
},
removeDelegationController(
token: AccessToken,
controllerDid: Did,
): Promise<Result<{ success: boolean }, ApiError>> {
return xrpcResult("_delegation.removeController", {
method: "POST",
token,
body: { controller_did: controllerDid },
});
},
createDelegatedAccount(
token: AccessToken,
handle: Handle,
email?: EmailAddress,
controllerScopes?: ScopeSet,
): Promise<Result<{ did: Did; handle: Handle }, ApiError>> {
return xrpcResult("_delegation.createDelegatedAccount", {
method: "POST",
token,
body: { handle, email, controllerScopes },
});
},
getDelegationAuditLog(
token: AccessToken,
limit: number,
offset: number,
): Promise<
Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError>
> {
return xrpcResult("_delegation.getAuditLog", {
token,
params: { limit: String(limit), offset: String(offset) },
});
},
async exportBlobs(token: AccessToken): Promise<Blob> {
const res = await authenticatedFetch(`${API_BASE}/_backup.exportBlobs`, {
token,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
throw new ApiError(res.status, errData.error, errData.message);
}
return res.blob();
},
};
export const typedApi = {
+12 -10
View File
@@ -1,11 +1,9 @@
import {
api,
ApiError,
type CreateAccountParams,
type CreateAccountResult,
typedApi,
} from "./api.ts";
import type { Session } from "./types/api.ts";
import { api, ApiError, typedApi, castSession } from "./api.ts";
import type {
CreateAccountParams,
CreateAccountResult,
Session,
} from "./types/api.ts";
import {
type AccessToken,
type Did,
@@ -436,8 +434,9 @@ export async function confirmSignup(
setLoading();
try {
const result = await api.confirmSignup(did, verificationCode);
setAuthenticated(result);
return ok(result);
const session = castSession(result);
setAuthenticated(session);
return ok(session);
} catch (e) {
const error = toAuthError(e);
setError(error);
@@ -467,6 +466,9 @@ export function setSession(session: {
handle: unsafeAsHandle(session.handle),
accessJwt: unsafeAsAccessToken(session.accessJwt),
refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
contactKind: "none",
accountKind: "active",
isAdmin: false,
};
setAuthenticated(newSession);
}
+78
View File
@@ -0,0 +1,78 @@
import type { AccessToken, Did, EmailAddress, Handle, ScopeSet } from "./types/branded.ts";
import type { Session } from "./types/api.ts";
import type {
DelegationAuditEntry,
DelegationControlledAccount,
DelegationController,
DelegationScopePreset,
SsoLinkedAccount,
} from "./types/api.ts";
import { api, ApiError } from "./api.ts";
import type { Result } from "./types/result.ts";
export interface AuthenticatedClient {
readonly token: AccessToken;
readonly session: Session;
getSsoLinkedAccounts(): Promise<{ accounts: SsoLinkedAccount[] }>;
listDelegationControllers(): Promise<
Result<{ controllers: DelegationController[] }, ApiError>
>;
listDelegationControlledAccounts(): Promise<
Result<{ accounts: DelegationControlledAccount[] }, ApiError>
>;
getDelegationScopePresets(): Promise<
Result<{ presets: DelegationScopePreset[] }, ApiError>
>;
addDelegationController(
controllerDid: Did,
grantedScopes: ScopeSet,
): Promise<Result<{ success: boolean }, ApiError>>;
removeDelegationController(
controllerDid: Did,
): Promise<Result<{ success: boolean }, ApiError>>;
createDelegatedAccount(
handle: Handle,
email?: EmailAddress,
controllerScopes?: ScopeSet,
): Promise<Result<{ did: Did; handle: Handle }, ApiError>>;
getDelegationAuditLog(
limit: number,
offset: number,
): Promise<
Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError>
>;
exportBlobs(): Promise<Blob>;
}
export function createAuthenticatedClient(
session: Session,
): AuthenticatedClient {
const token = session.accessJwt;
return {
token,
session,
getSsoLinkedAccounts: () => api.getSsoLinkedAccounts(token),
listDelegationControllers: () => api.listDelegationControllers(token),
listDelegationControlledAccounts: () =>
api.listDelegationControlledAccounts(token),
getDelegationScopePresets: () => api.getDelegationScopePresets(),
addDelegationController: (controllerDid, grantedScopes) =>
api.addDelegationController(token, controllerDid, grantedScopes),
removeDelegationController: (controllerDid) =>
api.removeDelegationController(token, controllerDid),
createDelegatedAccount: (handle, email, controllerScopes) =>
api.createDelegatedAccount(token, handle, email, controllerScopes),
getDelegationAuditLog: (limit, offset) =>
api.getDelegationAuditLog(token, limit, offset),
exportBlobs: () => api.exportBlobs(token),
};
}
export { ApiError };
@@ -33,8 +33,10 @@ function apiLog(
export class AtprotoClient {
private baseUrl: string;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private dpopKeyPair: DPoPKeyPair | null = null;
private dpopNonce: string | null = null;
private isRefreshing = false;
constructor(pdsUrl: string) {
this.baseUrl = pdsUrl.replace(/\/$/, "");
@@ -48,6 +50,14 @@ export class AtprotoClient {
return this.accessToken;
}
setRefreshToken(token: string | null) {
this.refreshToken = token;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
getBaseUrl(): string {
return this.baseUrl;
}
@@ -56,6 +66,69 @@ export class AtprotoClient {
this.dpopKeyPair = keyPair;
}
private async tryRefreshToken(): Promise<boolean> {
if (!this.refreshToken || this.isRefreshing) return false;
this.isRefreshing = true;
try {
const session = await this.refreshSessionInternal(this.refreshToken);
this.accessToken = session.accessJwt;
this.refreshToken = session.refreshJwt;
return true;
} catch {
return false;
} finally {
this.isRefreshing = false;
}
}
private async refreshSessionInternal(refreshJwt: string): Promise<Session> {
const url = `${this.baseUrl}/xrpc/com.atproto.server.refreshSession`;
const headers: Record<string, string> = {};
if (this.dpopKeyPair) {
headers["Authorization"] = `DPoP ${refreshJwt}`;
const tokenHash = await computeAccessTokenHash(refreshJwt);
const dpopProof = await createDPoPProof(
this.dpopKeyPair,
"POST",
url,
this.dpopNonce ?? undefined,
tokenHash,
);
headers["DPoP"] = dpopProof;
} else {
headers["Authorization"] = `Bearer ${refreshJwt}`;
}
let res = await fetch(url, { method: "POST", headers });
if (!res.ok && this.dpopKeyPair) {
const dpopNonce = res.headers.get("DPoP-Nonce");
if (dpopNonce && dpopNonce !== this.dpopNonce) {
this.dpopNonce = dpopNonce;
headers["DPoP"] = await createDPoPProof(
this.dpopKeyPair,
"POST",
url,
dpopNonce,
await computeAccessTokenHash(refreshJwt),
);
res = await fetch(url, { method: "POST", headers });
}
}
if (!res.ok) {
throw new Error("Token refresh failed");
}
const newNonce = res.headers.get("DPoP-Nonce");
if (newNonce) {
this.dpopNonce = newNonce;
}
return res.json();
}
private async xrpc<T>(
method: string,
options?: {
@@ -135,6 +208,46 @@ export class AtprotoClient {
error: "Unknown",
message: res.statusText,
}));
const isTokenExpired = res.status === 401 &&
(err.error === "ExpiredToken" || err.error === "invalid_token" ||
(err.message && err.message.includes("expired")));
if (isTokenExpired && !authToken && await this.tryRefreshToken()) {
const retryNonce = res.headers.get("DPoP-Nonce") ?? this.dpopNonce;
if (retryNonce) this.dpopNonce = retryNonce;
res = await makeRequest(this.dpopNonce ?? undefined);
if (!res.ok && this.dpopKeyPair) {
const dpopNonce = res.headers.get("DPoP-Nonce");
if (dpopNonce && dpopNonce !== this.dpopNonce) {
this.dpopNonce = dpopNonce;
res = await makeRequest(dpopNonce);
}
}
if (res.ok) {
const newNonce = res.headers.get("DPoP-Nonce");
if (newNonce) this.dpopNonce = newNonce;
const responseContentType = res.headers.get("content-type") ?? "";
if (responseContentType.includes("application/json")) {
return res.json();
}
return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
}
const retryErr = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
const retryError = new Error(retryErr.message || retryErr.error || res.statusText) as
& Error
& { status: number; error: string };
retryError.status = res.status;
retryError.error = retryErr.error;
throw retryError;
}
const error = new Error(err.message || err.error || res.statusText) as
& Error
& {
@@ -261,6 +261,7 @@ export function createInboundMigrationFlow() {
state.sourceAccessToken = tokenResponse.access_token;
state.sourceRefreshToken = tokenResponse.refresh_token ?? null;
sourceClient.setAccessToken(tokenResponse.access_token);
sourceClient.setRefreshToken(tokenResponse.refresh_token ?? null);
sourceClient.setDPoPKeyPair(dpopKeyPair);
cleanupOAuthSessionData();
+1 -50
View File
@@ -1,7 +1,5 @@
import {
buildUrl,
isValidRoute,
parseRouteParams,
type Route,
type RouteParams,
routes,
@@ -71,19 +69,6 @@ export function navigate<R extends Route>(
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);
}
updateState();
}
export function getCurrentPath(): AppPath {
return state.current.path;
}
@@ -100,43 +85,9 @@ 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 { type Route, type RouteParams, routes, type RoutesWithParams };
export { type Route, type RouteParams, routes };
+110 -15
View File
@@ -10,6 +10,7 @@ import type {
Nsid,
PublicKeyMultibase,
RefreshToken,
ScopeSet,
} from "./branded.ts";
export type ApiErrorCode =
@@ -52,21 +53,76 @@ 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;
preferredLocale?: string | null;
isAdmin?: boolean;
active?: boolean;
status?: AccountStatus;
migratedToPds?: string;
migratedAt?: ISODateString;
accessJwt: AccessToken;
refreshJwt: RefreshToken;
export type ContactState =
| {
readonly contactKind: "channel";
readonly preferredChannel: VerificationChannel;
readonly preferredChannelVerified: boolean;
readonly email?: EmailAddress;
}
| {
readonly contactKind: "email";
readonly email: EmailAddress;
readonly emailConfirmed: boolean;
}
| { readonly contactKind: "none" };
export type AccountState =
| { readonly accountKind: "active"; readonly isAdmin: boolean }
| {
readonly accountKind: "migrated";
readonly migratedToPds: string;
readonly migratedAt: ISODateString;
readonly isAdmin: boolean;
}
| { readonly accountKind: "deactivated"; readonly isAdmin: boolean }
| { readonly accountKind: "suspended"; readonly isAdmin: boolean };
type SessionBase = {
readonly did: Did;
readonly handle: Handle;
readonly accessJwt: AccessToken;
readonly refreshJwt: RefreshToken;
readonly preferredLocale?: string | null;
};
export type Session = SessionBase & ContactState & AccountState;
export function hasEmail(
session: Session,
): session is Session & { email: EmailAddress } {
return session.contactKind === "email" ||
(session.contactKind === "channel" && session.email !== undefined);
}
export function getSessionEmail(session: Session): EmailAddress | undefined {
return session.contactKind === "email"
? session.email
: session.contactKind === "channel"
? session.email
: undefined;
}
export function isEmailVerified(session: Session): boolean {
return session.contactKind === "email"
? session.emailConfirmed
: session.contactKind === "channel"
? session.preferredChannelVerified
: false;
}
export function isMigrated(
session: Session,
): session is Session & { accountKind: "migrated" } {
return session.accountKind === "migrated";
}
export function isDeactivated(session: Session): boolean {
return session.accountKind === "deactivated";
}
export function isActive(session: Session): boolean {
return session.accountKind === "active";
}
export interface VerificationMethod {
@@ -493,3 +549,42 @@ export interface VerifyMigrationEmailResponse {
export interface ResendMigrationVerificationResponse {
sent: boolean;
}
export interface SsoLinkedAccount {
id: string;
provider: string;
provider_name: string;
provider_username: string;
provider_email?: string;
created_at: ISODateString;
last_login_at?: ISODateString;
}
export interface DelegationController {
did: Did;
grantedScopes: ScopeSet;
grantedAt: ISODateString;
isActive: boolean;
}
export interface DelegationControlledAccount {
did: Did;
handle: Handle;
grantedScopes: ScopeSet;
grantedAt: ISODateString;
}
export interface DelegationScopePreset {
name: string;
scopes: ScopeSet;
description: string;
}
export interface DelegationAuditEntry {
id: string;
action: string;
actor_did: Did;
target_did?: Did;
details?: string;
created_at: ISODateString;
}
+5
View File
@@ -23,6 +23,7 @@ export type InviteCode = Brand<string, "InviteCode">;
export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">;
export type DidKeyString = Brand<string, "DidKeyString">;
export type ScopeSet = Brand<string, "ScopeSet">;
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/;
const DID_WEB_REGEX = /^did:web:.+$/;
@@ -179,6 +180,10 @@ export function unsafeAsDidKey(s: string): DidKeyString {
return s as DidKeyString;
}
export function unsafeAsScopeSet(s: string): ScopeSet {
return s as ScopeSet;
}
export function parseAtUri(
uri: AtUri,
): { repo: Did; collection: Nsid; rkey: Rkey } {
+67
View File
@@ -0,0 +1,67 @@
declare const __step: unique symbol;
export type TotpIdle = {
readonly step: "idle";
readonly [__step]: "idle";
};
export type TotpQr = {
readonly step: "qr";
readonly qrBase64: string;
readonly totpUri: string;
readonly [__step]: "qr";
};
export type TotpVerify = {
readonly step: "verify";
readonly qrBase64: string;
readonly totpUri: string;
readonly [__step]: "verify";
};
export type TotpBackup = {
readonly step: "backup";
readonly backupCodes: readonly string[];
readonly [__step]: "backup";
};
export type TotpSetupState = TotpIdle | TotpQr | TotpVerify | TotpBackup;
export const idleState: TotpIdle = { step: "idle" } as TotpIdle;
export function qrState(qrBase64: string, totpUri: string): TotpQr {
return { step: "qr", qrBase64, totpUri } as TotpQr;
}
export function verifyState(state: TotpQr): TotpVerify {
return { step: "verify", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpVerify;
}
export function backupState(state: TotpVerify, backupCodes: readonly string[]): TotpBackup {
void state;
return { step: "backup", backupCodes } as TotpBackup;
}
export function goBackToQr(state: TotpVerify): TotpQr {
return { step: "qr", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpQr;
}
export function finish(_state: TotpBackup): TotpIdle {
return idleState;
}
export function isIdle(state: TotpSetupState): state is TotpIdle {
return state.step === "idle";
}
export function isQr(state: TotpSetupState): state is TotpQr {
return state.step === "qr";
}
export function isVerify(state: TotpSetupState): state is TotpVerify {
return state.step === "verify";
}
export function isBackup(state: TotpSetupState): state is TotpBackup {
return state.step === "backup";
}
+102 -113
View File
@@ -1,45 +1,24 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest } from '../lib/oauth'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { navigate } from '../lib/router.svelte'
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest, setDPoPNonce } from '../lib/oauth'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import type { Session, DelegationControlledAccount } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
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)
let actAsStarted = $state(false)
function getDid(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('did')
}
$effect(() => {
if (!authLoading && !session && !actAsInProgress) {
navigate(routes.login)
}
})
async function initiateActAs(session: Session, client: AuthenticatedClient) {
if (actAsStarted) return
actAsStarted = true
$effect(() => {
if (session && !actAsInProgress) {
actAsInProgress = true
initiateActAs()
}
})
async function initiateActAs() {
const did = getDid()
if (!did) {
error = $_('actAs.noAccountSpecified')
@@ -47,87 +26,89 @@
return
}
try {
const response = await fetch(
`/xrpc/_delegation.listControlledAccounts`,
{
headers: { 'Authorization': `Bearer ${session!.accessJwt}` }
}
)
const result = await client.listDelegationControlledAccounts()
if (!result.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
if (!response.ok) {
error = $_('actAs.failedToVerify')
loading = false
return
}
const account = result.value.accounts?.find((a: DelegationControlledAccount) => a.did === did)
const data = await response.json()
const account = data.accounts?.find((a: { did: string }) => a.did === did)
if (!account) {
error = $_('actAs.noAccess')
loading = false
return
}
if (!account) {
error = $_('actAs.noAccess')
loading = false
return
}
const hostname = window.location.origin
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
saveOAuthState({ state, codeVerifier })
const hostname = window.location.origin
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
saveOAuthState({ state, codeVerifier })
const parResponse = await fetch('/oauth/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: `${hostname}/oauth/client-metadata.json`,
redirect_uri: `${hostname}/app/`,
response_type: 'code',
scope: 'atproto',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
login_hint: account.handle
})
const parResponse = await fetch('/oauth/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: `${hostname}/oauth/client-metadata.json`,
redirect_uri: `${hostname}/app/`,
response_type: 'code',
scope: 'atproto',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
login_hint: account.handle
})
})
if (!parResponse.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
if (!parResponse.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
const parData = await parResponse.json()
if (!parData.request_uri) {
error = $_('actAs.invalidResponse')
loading = false
return
}
const parData = await parResponse.json()
if (!parData.request_uri) {
error = $_('actAs.invalidResponse')
loading = false
return
}
const authUrl = `${window.location.origin}/oauth/delegation/auth-token`
const dpopProof = await createDPoPProofForRequest('POST', authUrl, session!.accessJwt)
const authResponse = await fetch('/oauth/delegation/auth-token', {
const authUrl = `${window.location.origin}/delegation/auth-token`
const body = JSON.stringify({
request_uri: parData.request_uri,
delegated_did: did
})
async function callAuthToken(retry: boolean): Promise<Response> {
const dpopProof = await createDPoPProofForRequest('POST', authUrl, session.accessJwt)
const response = await fetch('/oauth/delegation/auth-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `DPoP ${session!.accessJwt}`,
'Authorization': `DPoP ${session.accessJwt}`,
'DPoP': dpopProof
},
body: JSON.stringify({
request_uri: parData.request_uri,
delegated_did: did
})
body
})
const authData = await authResponse.json()
if (authData.success && authData.redirect_uri) {
window.location.href = authData.redirect_uri
} else {
error = authData.error || $_('actAs.failedToInitiate')
loading = false
if (!response.ok && retry) {
const nonce = response.headers.get('DPoP-Nonce')
if (nonce) {
setDPoPNonce(nonce)
return callAuthToken(false)
}
}
} catch (e) {
error = $_('actAs.failedError', { values: { error: e instanceof Error ? e.message : String(e) } })
return response
}
const authResponse = await callAuthToken(true)
const authData = await authResponse.json()
if (authData.success && authData.redirect_uri) {
window.location.href = authData.redirect_uri
} else {
error = authData.error || $_('actAs.failedToInitiate')
loading = false
}
}
@@ -135,29 +116,37 @@
function goBack() {
navigate('/controllers')
}
function handleReady(session: Session, client: AuthenticatedClient) {
initiateActAs(session, client)
}
</script>
<div class="page">
{#if loading}
<div class="loading">
<p>{$_('actAs.preparing')}</p>
</div>
{:else}
<header>
<h1>{$_('actAs.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
{#if loading}
<div class="loading">
<p>{$_('actAs.preparing')}</p>
</div>
{:else}
<header>
<h1>{$_('actAs.title')}</h1>
</header>
{#if error}
<div class="message error">{error}</div>
{/if}
{#if error}
<div class="message error">{error}</div>
{/if}
<div class="actions">
<button class="back-btn" onclick={goBack}>
{$_('actAs.backToControllers')}
</button>
<div class="actions">
<button class="back-btn" onclick={goBack}>
{$_('actAs.backToControllers')}
</button>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
+273 -347
View File
@@ -1,23 +1,25 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import type { AuthenticatedClient } from '../lib/authenticated-client'
import { unsafeAsDid, unsafeAsHandle, unsafeAsEmail, unsafeAsScopeSet } from '../lib/types/branded'
import type { Did, Handle, ScopeSet } from '../lib/types/branded'
interface Controller {
did: string
handle: string
grantedScopes: string
did: Did
handle: Handle
grantedScopes: ScopeSet
grantedAt: string
isActive: boolean
}
interface ControlledAccount {
did: string
handle: string
grantedScopes: string
did: Did
handle: Handle
grantedScopes: ScopeSet
grantedAt: string
}
@@ -25,22 +27,9 @@
name: string
label: string
description: string
scopes: string
scopes: ScopeSet
}
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 controllers = $state<Controller[]>([])
let controlledAccounts = $state<ControlledAccount[]>([])
@@ -63,394 +52,332 @@
let newDelegatedScopes = $state('atproto')
let creatingDelegated = $state(false)
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
let currentClient: AuthenticatedClient | null = $state(null)
$effect(() => {
if (session) {
loadData()
}
})
function handleReady(_session: Session, client: AuthenticatedClient) {
currentClient = client
loadData(client)
}
async function loadData() {
async function loadData(client: AuthenticatedClient) {
loading = true
try {
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
} finally {
loading = false
await Promise.all([loadControllers(client), loadControlledAccounts(client), loadScopePresets(client)])
loading = false
}
async function loadControllers(client: AuthenticatedClient) {
const result = await client.listDelegationControllers()
if (result.ok) {
controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({
did: c.did,
handle: c.did as unknown as Handle,
grantedScopes: c.grantedScopes,
grantedAt: c.grantedAt,
isActive: c.isActive
}))
}
}
async function loadControllers() {
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControllers', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
controllers = data.controllers || []
}
} catch (e) {
console.error('Failed to load controllers:', e)
async function loadControlledAccounts(client: AuthenticatedClient) {
const result = await client.listDelegationControlledAccounts()
if (result.ok) {
controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({
did: a.did,
handle: a.handle,
grantedScopes: a.grantedScopes,
grantedAt: a.grantedAt
}))
}
}
async function loadControlledAccounts() {
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
controlledAccounts = data.accounts || []
}
} catch (e) {
console.error('Failed to load controlled accounts:', e)
}
}
async function loadScopePresets() {
try {
const response = await fetch('/xrpc/_delegation.getScopePresets')
if (response.ok) {
const data = await response.json()
scopePresets = data.presets || []
}
} catch (e) {
console.error('Failed to load scope presets:', e)
async function loadScopePresets(client: AuthenticatedClient) {
const result = await client.getDelegationScopePresets()
if (result.ok) {
scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({
name: p.name,
label: p.name,
description: p.description,
scopes: unsafeAsScopeSet(p.scopes)
}))
}
}
async function addController() {
if (!session || !addControllerDid.trim()) return
if (!currentClient || !addControllerDid.trim()) return
addingController = true
try {
const response = await fetch('/xrpc/_delegation.addController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
controller_did: addControllerDid.trim(),
granted_scopes: addControllerScopes
})
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToAddController'))
return
}
const controllerDid = unsafeAsDid(addControllerDid.trim())
const scopes = unsafeAsScopeSet(addControllerScopes)
const result = await currentClient.addDelegationController(controllerDid, scopes)
if (result.ok) {
toast.success($_('delegation.controllerAdded'))
addControllerDid = ''
addControllerScopes = 'atproto'
addControllerConfirmed = false
showAddController = false
await loadControllers()
} catch (e) {
toast.error($_('delegation.failedToAddController'))
} finally {
addingController = false
await loadControllers(currentClient)
}
addingController = false
}
async function removeController(controllerDid: string) {
if (!session) return
async function removeController(controllerDid: Did) {
if (!currentClient) return
if (!confirm($_('delegation.removeConfirm'))) return
try {
const response = await fetch('/xrpc/_delegation.removeController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ controller_did: controllerDid })
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
return
}
const result = await currentClient.removeDelegationController(controllerDid)
if (result.ok) {
toast.success($_('delegation.controllerRemoved'))
await loadControllers()
} catch (e) {
toast.error($_('delegation.failedToRemoveController'))
await loadControllers(currentClient)
}
}
async function createDelegatedAccount() {
if (!session || !newDelegatedHandle.trim()) return
if (!currentClient || !newDelegatedHandle.trim()) return
creatingDelegated = true
try {
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
handle: newDelegatedHandle.trim(),
email: newDelegatedEmail.trim() || undefined,
controllerScopes: newDelegatedScopes
})
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
return
}
const data = await response.json()
toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
const handle = unsafeAsHandle(newDelegatedHandle.trim())
const email = newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined
const scopes = unsafeAsScopeSet(newDelegatedScopes)
const result = await currentClient.createDelegatedAccount(handle, email, scopes)
if (result.ok) {
toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } }))
newDelegatedHandle = ''
newDelegatedEmail = ''
newDelegatedScopes = 'atproto'
showCreateDelegated = false
await loadControlledAccounts()
} catch (e) {
toast.error($_('delegation.failedToCreateAccount'))
} finally {
creatingDelegated = false
await loadControlledAccounts(currentClient)
}
creatingDelegated = false
}
function getScopeLabel(scopes: string): string {
function getScopeLabel(scopes: ScopeSet): string {
const preset = scopePresets.find(p => p.scopes === scopes)
if (preset) return preset.label
if (scopes === 'atproto') return $_('delegation.scopeOwner')
if (scopes === '') return $_('delegation.scopeViewer')
if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner')
if ((scopes as string) === '') return $_('delegation.scopeViewer')
return $_('delegation.scopeCustom')
}
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('delegation.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('delegation.title')}</h1>
</header>
{#if loading}
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controllers')}</h2>
<p class="section-description">{$_('delegation.controllersDesc')}</p>
</div>
{#if controllers.length === 0}
<p class="empty">{$_('delegation.noControllers')}</p>
{#if loading}
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
<div class="items-list">
{#each controllers as controller}
<div class="item-card" class:inactive={!controller.isActive}>
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{controller.handle}</span>
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
{#if !controller.isActive}
<span class="badge inactive">{$_('delegation.inactive')}</span>
{/if}
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controllers')}</h2>
<p class="section-description">{$_('delegation.controllersDesc')}</p>
</div>
{#if controllers.length === 0}
<p class="empty">{$_('delegation.noControllers')}</p>
{:else}
<div class="items-list">
{#each controllers as controller}
<div class="item-card" class:inactive={!controller.isActive}>
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{controller.handle}</span>
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
{#if !controller.isActive}
<span class="badge inactive">{$_('delegation.inactive')}</span>
{/if}
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
</div>
</div>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
<div class="item-actions">
<button class="danger-outline" onclick={() => removeController(controller.did)}>
{$_('delegation.remove')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canAddControllers}
<div class="constraint-notice">
<p>{$_('delegation.cannotAddControllers')}</p>
</div>
{:else if showAddController}
<div class="form-card">
<h3>{$_('delegation.addController')}</h3>
<div class="warning-box">
<div class="warning-header">
<svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>{$_('delegation.addControllerWarningTitle')}</span>
</div>
<p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
<ul class="warning-bullets">
<li>{$_('delegation.addControllerWarningBullet1')}</li>
<li>{$_('delegation.addControllerWarningBullet2')}</li>
<li>{$_('delegation.addControllerWarningBullet3')}</li>
</ul>
</div>
<div class="item-actions">
<button class="danger-outline" onclick={() => removeController(controller.did)}>
{$_('delegation.remove')}
<div class="field">
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
<input
id="controllerDid"
type="text"
bind:value={addControllerDid}
placeholder="did:plc:..."
disabled={addingController}
/>
</div>
<div class="field">
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
<select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<label class="confirm-checkbox">
<input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
<span>{$_('delegation.addControllerConfirm')}</span>
</label>
<div class="form-actions">
<button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
{$_('common.cancel')}
</button>
<button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canAddControllers}
<div class="constraint-notice">
<p>{$_('delegation.cannotAddControllers')}</p>
</div>
{:else if showAddController}
<div class="form-card">
<h3>{$_('delegation.addController')}</h3>
<div class="warning-box">
<div class="warning-header">
<svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>{$_('delegation.addControllerWarningTitle')}</span>
</div>
<p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
<ul class="warning-bullets">
<li>{$_('delegation.addControllerWarningBullet1')}</li>
<li>{$_('delegation.addControllerWarningBullet2')}</li>
<li>{$_('delegation.addControllerWarningBullet3')}</li>
</ul>
</div>
<div class="field">
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
<input
id="controllerDid"
type="text"
bind:value={addControllerDid}
placeholder="did:plc:..."
disabled={addingController}
/>
</div>
<div class="field">
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
<select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<label class="confirm-checkbox">
<input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
<span>{$_('delegation.addControllerConfirm')}</span>
</label>
<div class="form-actions">
<button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
{$_('common.cancel')}
</button>
<button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
{:else}
<button class="ghost full-width" onclick={() => showAddController = true}>
{$_('delegation.addControllerButton')}
</button>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controlledAccounts')}</h2>
<p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
</div>
</div>
{:else}
<button class="ghost full-width" onclick={() => showAddController = true}>
{$_('delegation.addControllerButton')}
</button>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controlledAccounts')}</h2>
<p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
</div>
{#if controlledAccounts.length === 0}
<p class="empty">{$_('delegation.noControlledAccounts')}</p>
{:else}
<div class="items-list">
{#each controlledAccounts as account}
<div class="item-card">
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{account.handle}</span>
<span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
{#if controlledAccounts.length === 0}
<p class="empty">{$_('delegation.noControlledAccounts')}</p>
{:else}
<div class="items-list">
{#each controlledAccounts as account}
<div class="item-card">
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{account.handle}</span>
<span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
</div>
</div>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
<div class="item-actions">
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
{$_('delegation.actAs')}
</a>
</div>
</div>
{/each}
</div>
{/if}
{#if !canControlAccounts}
<div class="constraint-notice">
<p>{$_('delegation.cannotControlAccounts')}</p>
</div>
{:else if showCreateDelegated}
<div class="form-card">
<h3>{$_('delegation.createDelegatedAccount')}</h3>
<div class="field">
<label for="delegatedHandle">{$_('delegation.handle')}</label>
<input
id="delegatedHandle"
type="text"
bind:value={newDelegatedHandle}
placeholder="username"
disabled={creatingDelegated}
/>
</div>
<div class="item-actions">
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
{$_('delegation.actAs')}
</a>
<div class="field">
<label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
<input
id="delegatedEmail"
type="email"
bind:value={newDelegatedEmail}
placeholder="email@example.com"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
<select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<div class="form-actions">
<button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
{$_('common.cancel')}
</button>
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canControlAccounts}
<div class="constraint-notice">
<p>{$_('delegation.cannotControlAccounts')}</p>
</div>
{:else if showCreateDelegated}
<div class="form-card">
<h3>{$_('delegation.createDelegatedAccount')}</h3>
<div class="field">
<label for="delegatedHandle">{$_('delegation.handle')}</label>
<input
id="delegatedHandle"
type="text"
bind:value={newDelegatedHandle}
placeholder="username"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
<input
id="delegatedEmail"
type="email"
bind:value={newDelegatedEmail}
placeholder="email@example.com"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
<select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<div class="form-actions">
<button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
{$_('common.cancel')}
{:else}
<button class="ghost full-width" onclick={() => showCreateDelegated = true}>
{$_('delegation.createDelegatedAccountButton')}
</button>
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
</button>
</div>
</div>
{:else}
<button class="ghost full-width" onclick={() => showCreateDelegated = true}>
{$_('delegation.createDelegatedAccountButton')}
</button>
{/if}
</section>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.auditLog')}</h2>
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
</div>
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
</section>
{/if}
</div>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.auditLog')}</h2>
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
</div>
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
</section>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
@@ -769,5 +696,4 @@
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
</style>
+8 -7
View File
@@ -11,6 +11,7 @@
import { isOk } from '../lib/types/result'
import { unsafeAsDid, type Did } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { isMigrated, isDeactivated, getSessionEmail, isEmailVerified } from '../lib/types/api'
import { onMount } from 'svelte'
const auth = $derived(getAuthState())
@@ -123,12 +124,12 @@
</div>
</header>
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<div class="migrated-banner">
<strong>{$_('dashboard.migratedTitle')}</strong>
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
</div>
{:else if session.status === 'deactivated' || session.active === false}
{:else if session.accountKind === 'deactivated'}
<div class="deactivated-banner">
<strong>{$_('dashboard.deactivatedTitle')}</strong>
<p>{$_('dashboard.deactivatedMessage')}</p>
@@ -144,15 +145,15 @@
{#if session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if session.status === 'deactivated' || session.active === false}
{:else if session.accountKind === 'deactivated'}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
<dt>{$_('dashboard.did')}</dt>
<dd class="mono">{session.did}</dd>
{#if session.preferredChannel}
{#if session.contactKind === 'channel'}
<dt>{$_('dashboard.primaryContact')}</dt>
<dd>
{#if session.preferredChannel === 'email'}
@@ -172,7 +173,7 @@
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{:else if session.email}
{:else if session.contactKind === 'email'}
<dt>{$_('register.email')}</dt>
<dd>
{session.email}
@@ -187,7 +188,7 @@
</section>
<nav class="nav-grid">
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card">
<h3>{$_('dashboard.navDidDocument')}</h3>
<p>{$_('dashboard.navDidDocumentDesc')}</p>
+98 -123
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import type { DelegationAuditEntry } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
interface AuditEntry {
id: string
@@ -16,76 +15,49 @@
createdAt: string
}
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 entries = $state<AuditEntry[]>([])
let total = $state(0)
let offset = $state(0)
const limit = 20
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
let currentClient: AuthenticatedClient | null = $state(null)
$effect(() => {
if (session) {
loadAuditLog()
}
})
function handleReady(_session: unknown, client: AuthenticatedClient) {
currentClient = client
loadAuditLog(client)
}
async function loadAuditLog() {
if (!session) return
async function loadAuditLog(client: AuthenticatedClient) {
loading = true
try {
const response = await fetch(
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
{
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
}
)
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog'))
return
}
const data = await response.json()
entries = data.entries || []
total = data.total || 0
} catch (e) {
toast.error($_('delegation.failedToLoadAuditLog'))
} finally {
loading = false
const result = await client.getDelegationAuditLog(limit, offset)
if (result.ok) {
entries = (result.value.entries ?? []).map((e: DelegationAuditEntry) => ({
id: e.id,
delegatedDid: e.target_did ?? '',
actorDid: e.actor_did,
controllerDid: null,
actionType: e.action,
actionDetails: e.details ? JSON.parse(e.details) : null,
createdAt: e.created_at
}))
total = result.value.total ?? 0
}
loading = false
}
function prevPage() {
if (offset > 0) {
if (offset > 0 && currentClient) {
offset = Math.max(0, offset - limit)
loadAuditLog()
loadAuditLog(currentClient)
}
}
function nextPage() {
if (offset + limit < total) {
if (offset + limit < total && currentClient) {
offset = offset + limit
loadAuditLog()
loadAuditLog(currentClient)
}
}
@@ -115,81 +87,85 @@
}
</script>
<div class="page">
<header>
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
<h1>{$_('delegation.auditLogTitle')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
<h1>{$_('delegation.auditLogTitle')}</h1>
</header>
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _}
<div class="skeleton-entry"></div>
{/each}
</div>
{:else}
{#if entries.length === 0}
<p class="empty">{$_('delegation.noActivity')}</p>
{:else}
<div class="audit-list">
{#each entries as entry}
<div class="audit-entry">
<div class="entry-header">
<span class="action-type">{formatActionType(entry.actionType)}</span>
<span class="timestamp">{formatDateTime(entry.createdAt)}</span>
</div>
<div class="entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span>
</div>
{#if entry.controllerDid}
<div class="detail">
<span class="label">{$_('delegation.controller')}</span>
<span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span>
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _}
<div class="skeleton-entry"></div>
{/each}
</div>
{:else}
{#if entries.length === 0}
<p class="empty">{$_('delegation.noActivity')}</p>
{:else}
<div class="audit-list">
{#each entries as entry}
<div class="audit-entry">
<div class="entry-header">
<span class="action-type">{formatActionType(entry.actionType)}</span>
<span class="timestamp">{formatDateTime(entry.createdAt)}</span>
</div>
{/if}
<div class="detail">
<span class="label">{$_('delegation.account')}</span>
<span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span>
</div>
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value details">{formatActionDetails(entry.actionDetails)}</span>
<div class="entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span>
</div>
{#if entry.controllerDid}
<div class="detail">
<span class="label">{$_('delegation.controller')}</span>
<span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span>
</div>
{/if}
<div class="detail">
<span class="label">{$_('delegation.account')}</span>
<span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span>
</div>
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value details">{formatActionDetails(entry.actionDetails)}</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
</div>
<div class="pagination">
<button
class="ghost"
onclick={prevPage}
disabled={offset === 0}
>
{$_('delegation.previous')}
</button>
<span class="page-info">
{$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })}
</span>
<button
class="ghost"
onclick={nextPage}
disabled={offset + limit >= total}
>
{$_('delegation.next')}
</button>
</div>
{/if}
<div class="pagination">
<button
class="ghost"
onclick={prevPage}
disabled={offset === 0}
>
{$_('delegation.previous')}
</button>
<span class="page-info">
{$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })}
</span>
<button
class="ghost"
onclick={nextPage}
disabled={offset + limit >= total}
>
{$_('delegation.next')}
</button>
</div>
{/if}
<div class="actions-bar">
<button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button>
<div class="actions-bar">
<button class="ghost" onclick={() => currentClient && loadAuditLog(currentClient)}>{$_('delegation.refresh')}</button>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
@@ -332,5 +308,4 @@
border-radius: var(--radius-lg);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
</style>
+92 -107
View File
@@ -1,18 +1,30 @@
<script lang="ts">
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { getValidToken } from '../lib/auth.svelte'
import { routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import ReauthModal from '../components/ReauthModal.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import { _ } from '../lib/i18n'
import { formatDate as formatDateUtil } from '../lib/date'
import type { Session } from '../lib/types/api'
import type { Session, SsoLinkedAccount } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import { toast } from '../lib/toast.svelte'
import {
type TotpSetupState,
idleState,
qrState,
verifyState,
backupState,
goBackToQr,
finish,
type TotpQr,
} from '../lib/types/totp-state'
interface SsoProvider {
provider: string
@@ -20,39 +32,16 @@
icon: string
}
interface LinkedAccount {
id: string
provider: string
provider_name: string
provider_username: string | null
provider_email: string | null
created_at: string
last_login_at: string | null
}
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 currentSession: Session | null = $state(null)
let currentClient: AuthenticatedClient | null = $state(null)
let loading = $state(true)
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
let qrBase64 = $state('')
let totpUri = $state('')
let totpSetup = $state<TotpSetupState>(idleState)
let verifyCodeRaw = $state('')
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
let verifyLoading = $state(false)
let backupCodes = $state<string[]>([])
let disablePassword = $state('')
let disableCode = $state('')
let disableLoading = $state(false)
@@ -87,7 +76,7 @@
let legacyLoginUpdating = $state(false)
let ssoProviders = $state<SsoProvider[]>([])
let linkedAccounts = $state<LinkedAccount[]>([])
let linkedAccounts = $state<SsoLinkedAccount[]>([])
let linkedAccountsLoading = $state(true)
let linkingProvider = $state<string | null>(null)
let unlinkingId = $state<string | null>(null)
@@ -97,13 +86,7 @@
let pendingAction = $state<(() => Promise<void>) | null>(null)
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (session) {
if (currentSession && currentClient) {
loadTotpStatus()
loadPasskeys()
loadPasswordStatus()
@@ -126,16 +109,11 @@
}
async function loadLinkedAccounts() {
if (!session) return
if (!currentClient) return
linkedAccountsLoading = true
try {
const response = await fetch('/oauth/sso/linked', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
linkedAccounts = data.accounts || []
}
const data = await currentClient.getSsoLinkedAccounts()
linkedAccounts = data.accounts || []
} catch {
linkedAccounts = []
} finally {
@@ -154,7 +132,7 @@
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
'Authorization': `Bearer ${currentSession?.accessJwt}`
},
body: JSON.stringify({
provider,
@@ -200,7 +178,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
'Authorization': `Bearer ${currentSession?.accessJwt}`
},
body: JSON.stringify({ id })
})
@@ -228,10 +206,10 @@
}
async function loadPasswordStatus() {
if (!session) return
if (!currentSession) return
passwordLoading = true
try {
const status = await api.getPasswordStatus(session.accessJwt)
const status = await api.getPasswordStatus(currentSession.accessJwt)
hasPassword = status.hasPassword
} catch {
hasPassword = true
@@ -241,10 +219,10 @@
}
async function loadLegacyLoginPreference() {
if (!session) return
if (!currentSession) return
legacyLoginLoading = true
try {
const pref = await api.getLegacyLoginPreference(session.accessJwt)
const pref = await api.getLegacyLoginPreference(currentSession.accessJwt)
allowLegacyLogin = pref.allowLegacyLogin
hasMfa = pref.hasMfa
} catch {
@@ -256,10 +234,10 @@
}
async function handleToggleLegacyLogin() {
if (!session) return
if (!currentSession) return
legacyLoginUpdating = true
try {
const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
const result = await api.updateLegacyLoginPreference(currentSession.accessJwt, !allowLegacyLogin)
allowLegacyLogin = result.allowLegacyLogin
toast.success(allowLegacyLogin
? $_('security.legacyLoginEnabled')
@@ -282,7 +260,7 @@
}
async function handleRemovePassword() {
if (!session) return
if (!currentSession) return
removePasswordLoading = true
try {
const token = await getValidToken()
@@ -323,10 +301,10 @@
}
async function loadTotpStatus() {
if (!session) return
if (!currentSession) return
loading = true
try {
const status = await api.getTotpStatus(session.accessJwt)
const status = await api.getTotpStatus(currentSession.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
} catch {
@@ -337,13 +315,11 @@
}
async function handleStartSetup() {
if (!session) return
if (!currentSession) return
verifyLoading = true
try {
const result = await api.createTotpSecret(session.accessJwt)
qrBase64 = result.qrBase64
totpUri = result.uri
setupStep = 'qr'
const result = await api.createTotpSecret(currentSession.accessJwt)
totpSetup = qrState(result.qrBase64, result.uri)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
@@ -353,12 +329,11 @@
async function handleVerifySetup(e: Event) {
e.preventDefault()
if (!session || !verifyCode) return
if (!currentSession || !verifyCode || totpSetup.step !== 'verify') return
verifyLoading = true
try {
const result = await api.enableTotp(session.accessJwt, verifyCode)
backupCodes = result.backupCodes
setupStep = 'backup'
const result = await api.enableTotp(currentSession.accessJwt, verifyCode)
totpSetup = backupState(totpSetup, result.backupCodes)
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
@@ -370,19 +345,17 @@
}
function handleFinishSetup() {
setupStep = 'idle'
backupCodes = []
qrBase64 = ''
totpUri = ''
if (totpSetup.step !== 'backup') return
totpSetup = finish(totpSetup)
toast.success($_('security.totpEnabledSuccess'))
}
async function handleDisable(e: Event) {
e.preventDefault()
if (!session || !disablePassword || !disableCode) return
if (!currentSession || !disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
await api.disableTotp(currentSession.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
@@ -398,12 +371,12 @@
async function handleRegenerate(e: Event) {
e.preventDefault()
if (!session || !regenPassword || !regenCode) return
if (!currentSession || !regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
backupCodes = result.backupCodes
setupStep = 'backup'
const result = await api.regenerateBackupCodes(currentSession.accessJwt, regenPassword, regenCode)
const dummyVerify = verifyState(qrState('', ''))
totpSetup = backupState(dummyVerify, result.backupCodes)
showRegenForm = false
regenPassword = ''
regenCode = ''
@@ -415,16 +388,17 @@
}
function copyBackupCodes() {
const text = backupCodes.join('\n')
if (totpSetup.step !== 'backup') return
const text = totpSetup.backupCodes.join('\n')
navigator.clipboard.writeText(text)
toast.success($_('security.backupCodesCopied'))
}
async function loadPasskeys() {
if (!session) return
if (!currentSession) return
passkeysLoading = true
try {
const result = await api.listPasskeys(session.accessJwt)
const result = await api.listPasskeys(currentSession.accessJwt)
passkeys = result.passkeys
} catch {
toast.error($_('security.failedToLoadPasskeys'))
@@ -434,14 +408,14 @@
}
async function handleAddPasskey() {
if (!session) return
if (!currentSession) return
if (!window.PublicKeyCredential) {
toast.error($_('security.passkeysNotSupported'))
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const { options } = await api.startPasskeyRegistration(currentSession.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
@@ -451,7 +425,7 @@
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
await api.finishPasskeyRegistration(currentSession.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
toast.success($_('security.passkeyAddedSuccess'))
@@ -467,12 +441,12 @@
}
async function handleDeletePasskey(id: string) {
if (!session) return
if (!currentSession) 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(session.accessJwt, id)
await api.deletePasskey(currentSession.accessJwt, id)
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
@@ -481,9 +455,9 @@
}
async function handleSavePasskeyName() {
if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
if (!currentSession || !editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await api.updatePasskey(currentSession.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
@@ -506,15 +480,22 @@
function formatDate(dateStr: string): string {
return formatDateUtil(dateStr)
}
function handleReady(session: Session, client: AuthenticatedClient) {
currentSession = session
currentClient = client
}
</script>
<div class="page">
<header>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('security.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('security.title')}</h1>
</header>
{#if loading}
{#if loading}
<div class="skeleton-grid">
{#each Array(4) as _}
<div class="skeleton-section"></div>
@@ -528,7 +509,7 @@
{$_('security.totpDescription')}
</p>
{#if setupStep === 'idle'}
{#if totpSetup.step === 'idle'}
{#if totpEnabled}
<div class="status enabled">
<span>{$_('security.totpEnabled')}</span>
@@ -632,22 +613,24 @@
{$_('security.enableTotp')}
</button>
{/if}
{:else if setupStep === 'qr'}
{:else if totpSetup.step === 'qr'}
{@const qrData = totpSetup as TotpQr}
<div class="setup-step">
<h3>{$_('security.totpSetup')}</h3>
<p>{$_('security.totpSetupInstructions')}</p>
<div class="qr-container">
<img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
</div>
<details class="manual-entry">
<summary>{$_('security.cantScan')}</summary>
<code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
</details>
<button onclick={() => setupStep = 'verify'}>
<button onclick={() => totpSetup = verifyState(qrData)}>
{$_('security.next')}
</button>
</div>
{:else if setupStep === 'verify'}
{:else if totpSetup.step === 'verify'}
{@const verifyData = totpSetup}
<div class="setup-step">
<h3>{$_('security.totpSetup')}</h3>
<p>{$_('security.totpCodePlaceholder')}</p>
@@ -663,7 +646,7 @@
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>
{$_('common.back')}
</button>
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
@@ -672,14 +655,14 @@
</div>
</form>
</div>
{:else if setupStep === 'backup'}
{:else if totpSetup.step === 'backup'}
<div class="setup-step">
<h3>{$_('security.backupCodes')}</h3>
<p class="warning-text">
{$_('security.backupCodesDescription')}
</p>
<div class="backup-codes">
{#each backupCodes as code}
{#each totpSetup.backupCodes as code}
<code class="backup-code">{code}</code>
{/each}
</div>
@@ -960,14 +943,16 @@
</section>
{/if}
{/if}
</div>
</div>
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
+9 -16
View File
@@ -7,8 +7,10 @@
import { isOk } from '../lib/types/result'
import { unsafeAsHandle } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { getSessionEmail } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import ReauthModal from '../components/ReauthModal.svelte'
import { createAuthenticatedClient } from '../lib/authenticated-client'
const auth = $derived(getAuthState())
const supportedLocales = getSupportedLocales()
@@ -24,6 +26,7 @@
const session = $derived(getSession())
const loading = $derived(isLoading())
const client = $derived(session ? createAuthenticatedClient(session) : null)
onMount(() => {
api.describeServer().then(info => {
@@ -276,19 +279,10 @@
}
async function handleExportBlobs() {
if (!session) return
if (!client) return
exportBlobsLoading = true
try {
const response = await fetch('/xrpc/_backup.exportBlobs', {
headers: {
'Authorization': `Bearer ${session.accessJwt}`
}
})
if (!response.ok) {
const err = await response.json().catch(() => ({ message: 'Export failed' }))
throw new Error(err.message || 'Export failed')
}
const blob = await response.blob()
const blob = await client.exportBlobs()
if (blob.size === 0) {
toast.success($_('settings.messages.noBlobsToExport'))
return
@@ -296,14 +290,13 @@
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${session.handle}-blobs.zip`
a.download = `${client.session.handle}-blobs.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success($_('settings.messages.blobsExported'))
} catch (e) {
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
} catch {
} finally {
exportBlobsLoading = false
}
@@ -531,8 +524,8 @@
</section>
<section>
<h2>{$_('settings.changeEmail')}</h2>
{#if session?.email}
<p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
{#if session && getSessionEmail(session)}
<p class="current">{$_('settings.currentEmail', { values: { email: getSessionEmail(session) } })}</p>
{/if}
{#if emailTokenRequired}
<form onsubmit={handleConfirmEmailUpdate}>
+6 -2
View File
@@ -611,8 +611,12 @@ hr {
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.section-hint {
+2
View File
@@ -10,6 +10,7 @@ import {
mockEndpoint,
setupAuthenticatedUser,
setupFetchMock,
setupIndexedDBMock,
setupUnauthenticatedUser,
} from "./mocks.ts";
import { unsafeAsISODateString } from "../lib/types/branded.ts";
@@ -17,6 +18,7 @@ describe("AppPasswords", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
globalThis.confirm = vi.fn(() => true);
});
describe("authentication guard", () => {
+2
View File
@@ -7,6 +7,7 @@ import {
mockData,
mockEndpoint,
setupFetchMock,
setupIndexedDBMock,
} from "./mocks.ts";
import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts";
import {
@@ -21,6 +22,7 @@ describe("Login", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
mockEndpoint(
"/oauth/par",
() => jsonResponse({ request_uri: "urn:mock:request" }),
+72 -9
View File
@@ -12,6 +12,63 @@ import {
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
export function setupIndexedDBMock(): void {
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
}
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
const originalReplaceState = globalThis.history.replaceState.bind(
globalThis.history,
@@ -165,15 +222,20 @@ export function errorResponse(
};
}
export const mockData = {
session: (overrides?: Partial<Session>): Session => ({
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
...overrides,
}),
session: (overrides?: Partial<Session>): Session => {
const base = {
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
contactKind: "email" as const,
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accountKind: "active" as const,
isAdmin: false,
};
return { ...base, ...overrides } as Session;
},
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
name: "Test App",
createdAt: unsafeAsISODateString(new Date().toISOString()),
@@ -225,6 +287,7 @@ export const mockData = {
};
export function setupDefaultMocks(): void {
setupFetchMock();
setupIndexedDBMock();
mockEndpoint(
"com.atproto.server.getSession",
() => jsonResponse(mockData.session()),
+2 -55
View File
@@ -6,71 +6,18 @@ import {
mockData,
mockEndpoint,
setupFetchMock,
setupIndexedDBMock,
} from "./mocks.ts";
import { _testSetState } from "../lib/auth.svelte.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
describe("OAuth Registration Flow", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
sessionStorage.clear();
vi.restoreAllMocks();
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
Object.defineProperty(globalThis.location, "search", {
value: "",
writable: true,