mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 04:04:14 +00:00
fix: make frontend more type-safe
This commit is contained in:
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user