sso signup & login

This commit is contained in:
lewis
2026-01-18 01:15:13 +02:00
parent 4e29861990
commit b3ec7feb96
141 changed files with 9982 additions and 314 deletions
+6
View File
@@ -8,6 +8,7 @@
import Login from './routes/Login.svelte'
import Register from './routes/Register.svelte'
import RegisterPasskey from './routes/RegisterPasskey.svelte'
import RegisterSso from './routes/RegisterSso.svelte'
import Verify from './routes/Verify.svelte'
import ResetPassword from './routes/ResetPassword.svelte'
import RecoverPasskey from './routes/RecoverPasskey.svelte'
@@ -28,6 +29,7 @@
import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthDelegation from './routes/OAuthDelegation.svelte'
import OAuthError from './routes/OAuthError.svelte'
import OAuthSsoRegister from './routes/OAuthSsoRegister.svelte'
import Security from './routes/Security.svelte'
import TrustedDevices from './routes/TrustedDevices.svelte'
import Controllers from './routes/Controllers.svelte'
@@ -100,6 +102,8 @@
return RegisterPasskey
case '/register-password':
return Register
case '/register-sso':
return RegisterSso
case '/verify':
return Verify
case '/reset-password':
@@ -140,6 +144,8 @@
return OAuthDelegation
case '/oauth/error':
return OAuthError
case '/oauth/sso-register':
return OAuthSsoRegister
case '/security':
return Security
case '/trusted-devices':
@@ -4,10 +4,11 @@
import { routes } from '../lib/types/routes'
interface Props {
active: 'passkey' | 'password'
active: 'passkey' | 'password' | 'sso'
ssoAvailable?: boolean
}
let { active }: Props = $props()
let { active, ssoAvailable = true }: Props = $props()
</script>
<div class="account-type-switcher">
@@ -17,6 +18,15 @@
<a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}>
{$_('register.passwordAccount')}
</a>
{#if ssoAvailable || active === 'sso'}
<a href={getFullUrl(routes.registerSso)} class="switcher-option" class:active={active === 'sso'}>
{$_('register.ssoAccount')}
</a>
{:else}
<span class="switcher-option disabled">
{$_('register.ssoAccount')}
</span>
{/if}
</div>
<style>
@@ -53,4 +63,14 @@
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.switcher-option.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.switcher-option.disabled:hover {
color: var(--text-secondary);
background: transparent;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
interface Props {
provider: string
size?: number
}
let { provider, size = 24 }: Props = $props()
</script>
{#if provider === 'github'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{:else if provider === 'discord'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</svg>
{:else if provider === 'google'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
{:else if provider === 'gitlab'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 00-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 00-.867 0L1.386 9.452.044 13.587a.924.924 0 00.331 1.023L12 23.054l11.625-8.443a.92.92 0 00.33-1.024" fill="#FC6D26"/>
<path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#E24329"/>
<path d="M12 23.054l-4.418-13.603H1.386L12 23.054z" fill="#FC6D26"/>
<path d="M1.386 9.451L.044 13.586a.924.924 0 00.331 1.023L12 23.054 1.386 9.451z" fill="#FCA326"/>
<path d="M1.386 9.452h6.196L4.918 1.263a.455.455 0 00-.867 0L1.386 9.452z" fill="#E24329"/>
<path d="M12 23.054l4.418-13.603h6.196L12 23.054z" fill="#FC6D26"/>
<path d="M22.614 9.451l1.342 4.135a.924.924 0 01-.331 1.023L12 23.054l10.614-13.603z" fill="#FCA326"/>
<path d="M22.614 9.452h-6.196l2.664-8.189a.455.455 0 01.867 0l2.665 8.189z" fill="#E24329"/>
</svg>
{:else if provider === 'apple'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/>
</svg>
{:else}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
{/if}
<style>
svg {
display: block;
}
</style>
+22 -3
View File
@@ -143,10 +143,13 @@ async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
return xrpc(method, { ...options, token: newToken, skipRetry: true });
}
}
const message = res.status === 429
? (errData.message || "Too many requests. Please try again later.")
: errData.message;
throw new ApiError(
res.status,
errData.error as ApiErrorCode,
errData.message,
message,
errData.did,
errData.reauthMethods,
);
@@ -382,10 +385,14 @@ export const api = {
});
},
requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> {
requestEmailUpdate(
token: AccessToken,
newEmail?: string,
): Promise<EmailUpdateResponse> {
return xrpc("com.atproto.server.requestEmailUpdate", {
method: "POST",
token,
body: newEmail ? { newEmail } : undefined,
});
},
@@ -401,6 +408,15 @@ export const api = {
});
},
checkEmailUpdateStatus(
token: AccessToken,
): Promise<{ pending: boolean; authorized: boolean; newEmail?: string }> {
return xrpc("_account.checkEmailUpdateStatus", {
method: "GET",
token,
});
},
async updateHandle(token: AccessToken, handle: Handle): Promise<void> {
await xrpc("com.atproto.identity.updateHandle", {
method: "POST",
@@ -540,7 +556,10 @@ export const api = {
});
},
setPassword(token: AccessToken, newPassword: string): Promise<SuccessResponse> {
setPassword(
token: AccessToken,
newPassword: string,
): Promise<SuccessResponse> {
return xrpc("_account.setPassword", {
method: "POST",
token,
+27 -10
View File
@@ -40,7 +40,9 @@ const migrateSingleBlob = async (
): Promise<MigrateBlobResult> => {
try {
console.log(
`[blob-migration] Fetching blob ${cid} from source (attempt ${attempt + 1})`,
`[blob-migration] Fetching blob ${cid} from source (attempt ${
attempt + 1
})`,
);
const { data: blobData, contentType } = await sourceClient
.getBlobWithContentType(userDid, cid);
@@ -59,7 +61,9 @@ const migrateSingleBlob = async (
} catch (e) {
const errorMessage = (e as Error).message || String(e);
console.error(
`[blob-migration] Failed to migrate blob ${cid} (attempt ${attempt + 1}):`,
`[blob-migration] Failed to migrate blob ${cid} (attempt ${
attempt + 1
}):`,
errorMessage,
);
@@ -115,15 +119,22 @@ export async function migrateBlobs(
console.log("[blob-migration] Starting blob migration for", userDid);
console.log(
"[blob-migration] Source client:",
sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE",
sourceClient
? `available (baseUrl: ${sourceClient.getBaseUrl()})`
: "NOT AVAILABLE",
);
console.log(
"[blob-migration] Local client baseUrl:",
localClient.getBaseUrl(),
);
console.log("[blob-migration] Local client baseUrl:", localClient.getBaseUrl());
console.log(
"[blob-migration] Local client has access token:",
localClient.getAccessToken() ? "yes" : "NO",
);
safeProgress(onProgress, { currentOperation: "Checking for missing blobs..." });
safeProgress(onProgress, {
currentOperation: "Checking for missing blobs...",
});
const missingBlobs = await collectMissingBlobs(localClient);
@@ -137,7 +148,9 @@ export async function migrateBlobs(
}
if (!sourceClient) {
console.warn("[blob-migration] No source client available, cannot fetch blobs");
console.warn(
"[blob-migration] No source client available, cannot fetch blobs",
);
safeProgress(onProgress, {
currentOperation:
`${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`,
@@ -161,7 +174,9 @@ export async function migrateBlobs(
const acc = await accPromise;
safeProgress(onProgress, {
currentOperation: `Migrating blob ${index + 1}/${missingBlobs.length}...`,
currentOperation: `Migrating blob ${
index + 1
}/${missingBlobs.length}...`,
blobsMigrated: acc.migrated,
});
@@ -186,12 +201,14 @@ export async function migrateBlobs(
const statusMessage = migrated === missingBlobs.length
? `All ${migrated} blobs migrated successfully`
: migrated > 0
? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`
: `Could not migrate blobs (${failed.length} missing)`;
? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`
: `Could not migrate blobs (${failed.length} missing)`;
safeProgress(onProgress, { currentOperation: statusMessage });
console.log(`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`);
console.log(
`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`,
);
failed.length > 0 && console.log("[blob-migration] Failed CIDs:", failed);
return {
+10 -4
View File
@@ -479,18 +479,24 @@ export function createInboundMigrationFlow() {
async function migrateBlobs(): Promise<void> {
if (!sourceClient) {
console.error("[migration] migrateBlobs: sourceClient is null, skipping blob migration");
console.error(
"[migration] migrateBlobs: sourceClient is null, skipping blob migration",
);
migrationLog("migrateBlobs SKIPPED: sourceClient is null");
setProgress({
currentOperation: "Warning: Could not migrate blobs - source PDS connection lost",
currentOperation:
"Warning: Could not migrate blobs - source PDS connection lost",
});
return;
}
if (!localClient) {
console.error("[migration] migrateBlobs: localClient is null, skipping blob migration");
console.error(
"[migration] migrateBlobs: localClient is null, skipping blob migration",
);
migrationLog("migrateBlobs SKIPPED: localClient is null");
setProgress({
currentOperation: "Warning: Could not migrate blobs - local PDS connection lost",
currentOperation:
"Warning: Could not migrate blobs - local PDS connection lost",
});
return;
}
+11 -3
View File
@@ -10,7 +10,7 @@ const SCOPES = [
"repo:*?action=delete",
"blob:*/*",
"identity:*",
"account:*",
"account:*?action=manage",
].join(" ");
const CLIENT_ID = !(import.meta.env.DEV)
@@ -346,7 +346,9 @@ async function tokenRequest(
extractDPoPNonceFromResponse(response);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
const error = await response.json().catch(() => ({
error: "Unknown error",
}));
if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) {
return tokenRequest(params, false);
@@ -431,5 +433,11 @@ export async function createDPoPProofForRequest(
const keyPair = await getOrCreateDPoPKeyPair();
const tokenHash = await sha256(accessToken);
const ath = base64UrlEncode(tokenHash);
return createDPoPProof(keyPair, method, url, getDPoPNonce() ?? undefined, ath);
return createDPoPProof(
keyPair,
method,
url,
getDPoPNonce() ?? undefined,
ath,
);
}
+14 -5
View File
@@ -19,9 +19,9 @@ import type {
SessionState,
} from "./types.ts";
import {
saveRegistrationState,
loadRegistrationState,
clearRegistrationState,
loadRegistrationState,
saveRegistrationState,
} from "./storage.ts";
export interface RegistrationFlowState {
@@ -433,7 +433,9 @@ export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>;
export function restoreRegistrationFlow(): RegistrationFlow | null {
const saved = loadRegistrationState();
if (!saved || saved.step === "info" || saved.step === "redirect-to-dashboard") {
if (
!saved || saved.step === "info" || saved.step === "redirect-to-dashboard"
) {
return null;
}
@@ -441,11 +443,18 @@ export function restoreRegistrationFlow(): RegistrationFlow | null {
flow.state.step = saved.step;
flow.state.info = { ...flow.state.info, ...saved.info };
flow.state.externalDidWeb = { ...flow.state.externalDidWeb, ...saved.externalDidWeb };
flow.state.externalDidWeb = {
...flow.state.externalDidWeb,
...saved.externalDidWeb,
};
flow.state.account = saved.account;
flow.state.session = saved.session;
return flow;
}
export { hasPendingRegistration, getRegistrationResumeInfo, clearRegistrationState } from "./storage.ts";
export {
clearRegistrationState,
getRegistrationResumeInfo,
hasPendingRegistration,
} from "./storage.ts";
+26 -23
View File
@@ -1,9 +1,9 @@
import type {
AccountResult,
ExternalDidWebState,
RegistrationInfo,
RegistrationMode,
RegistrationStep,
RegistrationInfo,
ExternalDidWebState,
AccountResult,
SessionState,
} from "./types.ts";
@@ -81,18 +81,18 @@ export function saveRegistrationState(
},
account: account
? {
did: account.did,
handle: account.handle,
setupToken: account.setupToken,
appPassword: account.appPassword,
appPasswordName: account.appPasswordName,
}
did: account.did,
handle: account.handle,
setupToken: account.setupToken,
appPassword: account.appPassword,
appPasswordName: account.appPasswordName,
}
: null,
session: session
? {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
}
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
}
: null,
};
@@ -144,18 +144,18 @@ export function loadRegistrationState(): {
},
account: state.account
? {
did: state.account.did as AccountResult["did"],
handle: state.account.handle as AccountResult["handle"],
setupToken: state.account.setupToken,
appPassword: state.account.appPassword,
appPasswordName: state.account.appPasswordName,
}
did: state.account.did as AccountResult["did"],
handle: state.account.handle as AccountResult["handle"],
setupToken: state.account.setupToken,
appPassword: state.account.appPassword,
appPasswordName: state.account.appPasswordName,
}
: null,
session: state.session
? {
accessJwt: state.session.accessJwt as SessionState["accessJwt"],
refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"],
}
accessJwt: state.session.accessJwt as SessionState["accessJwt"],
refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"],
}
: null,
};
} catch {
@@ -172,7 +172,8 @@ export function clearRegistrationState(): void {
export function hasPendingRegistration(): boolean {
const state = loadRegistrationState();
return state !== null && state.step !== "info" && state.step !== "redirect-to-dashboard";
return state !== null && state.step !== "info" &&
state.step !== "redirect-to-dashboard";
}
export function getRegistrationResumeInfo(): {
@@ -182,7 +183,9 @@ export function getRegistrationResumeInfo(): {
did?: string;
} | null {
const state = loadRegistrationState();
if (!state || state.step === "info" || state.step === "redirect-to-dashboard") {
if (
!state || state.step === "info" || state.step === "redirect-to-dashboard"
) {
return null;
}
+3
View File
@@ -2,6 +2,7 @@ export const routes = {
login: "/login",
register: "/register",
registerPassword: "/register-password",
registerSso: "/register-sso",
dashboard: "/dashboard",
settings: "/settings",
security: "/security",
@@ -29,6 +30,7 @@ export const routes = {
oauthPasskey: "/oauth/passkey",
oauthDelegation: "/oauth/delegation",
oauthError: "/oauth/error",
oauthSsoRegister: "/oauth/sso-register",
} as const;
export type Route = (typeof routes)[keyof typeof routes];
@@ -52,6 +54,7 @@ export interface RouteParams {
[routes.oauthDelegation]: { request_uri?: string; delegated_did?: string };
[routes.oauthError]: { error?: string; error_description?: string };
[routes.migrate]: { code?: string; state?: string };
[routes.oauthSsoRegister]: { token?: string };
}
export type RoutesWithParams = keyof RouteParams;
+44 -3
View File
@@ -170,6 +170,11 @@
"signIn": "Sign in",
"passkeyAccount": "Passkey",
"passwordAccount": "Password",
"ssoAccount": "SSO",
"ssoSubtitle": "Create an account using an external provider",
"noSsoProviders": "No SSO providers are configured on this server.",
"ssoHint": "Choose a provider to create your account:",
"continueWith": "Continue with {provider}",
"validation": {
"handleRequired": "Handle is required",
"handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.",
@@ -275,6 +280,8 @@
"verificationCode": "Verification Code",
"verificationCodePlaceholder": "Enter verification code",
"confirmEmailChange": "Confirm Email Change",
"emailTokenHint": "Enter the code from the email, or click the link in the email on any device.",
"emailUpdateAuthorized": "Email change authorized! Click confirm to complete.",
"updating": "Updating...",
"changeHandle": "Change Handle",
"currentHandle": "Current: @{handle}",
@@ -677,7 +684,7 @@
"checkingPasskey": "Checking passkey...",
"signInWithPasskey": "Sign in with passkey",
"passkeyNotSetUp": "Passkey not set up",
"orUsePassword": "or use password",
"orUsePassword": "Or use password",
"password": "Password",
"rememberDevice": "Remember this device",
"passkeyHintChecking": "Checking passkey status...",
@@ -685,7 +692,21 @@
"passkeyHintNotAvailable": "No passkeys registered for this account",
"passkeyHint": "Use your device's biometrics or security key",
"passwordPlaceholder": "Enter your password",
"usePasskey": "Use Passkey"
"usePasskey": "Use Passkey",
"orContinueWith": "Or continue with",
"orUseCredentials": "Or sign in with credentials"
},
"sso": {
"linkedAccounts": "Linked Accounts",
"linkedAccountsDesc": "External accounts linked to your identity for single sign-on.",
"noLinkedAccounts": "No linked accounts",
"noLinkedAccountsDesc": "Link an external account to enable quick sign-in with that provider.",
"linkAccount": "Link Account",
"unlinkAccount": "Unlink",
"unlinkConfirm": "Are you sure you want to unlink this account?",
"unlinked": "Unlinked {provider}",
"lastLoginAt": "Last used",
"linkedAt": "Linked"
},
"consent": {
"title": "Authorize Application",
@@ -798,6 +819,24 @@
"backToApp": "Back to Application"
}
},
"sso_register": {
"title": "Complete Registration",
"subtitle": "Creating account with {provider}",
"handle_label": "Choose your handle",
"handle_available": "Available",
"handle_taken": "Already taken",
"submit": "Create Account",
"error_expired": "Registration session expired. Please try again.",
"error_handle_required": "Please choose a handle",
"emailVerifiedByProvider": "This email is verified by {provider}. No additional verification needed.",
"emailChangedNeedsVerification": "If you use a different email, you will need to verify it.",
"infoAfterTitle": "After creating your account",
"infoAddPassword": "Add a password for traditional login",
"infoAddPasskey": "Set up a passkey for passwordless sign-in",
"infoLinkProviders": "Link additional SSO providers",
"infoChangeHandle": "Change your handle or use a custom domain",
"tryAgain": "Try again"
},
"verify": {
"title": "Verify Your Account",
"subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.",
@@ -834,7 +873,9 @@
"updateEmail": "Update Email",
"updating": "Updating...",
"emailUpdated": "Your email has been updated successfully.",
"emailUpdatedInfo": "You may need to verify your new email address."
"emailUpdatedInfo": "You may need to verify your new email address.",
"emailAuthorizeSuccess": "Your email update has been authorized.",
"emailAuthorizeInfo": "You can now complete the change on your original device."
},
"resetPassword": {
"title": "Reset Password",
+36 -1
View File
@@ -170,6 +170,11 @@
"signIn": "Kirjaudu sisään",
"passkeyAccount": "Pääsyavain",
"passwordAccount": "Salasana",
"ssoAccount": "SSO",
"ssoSubtitle": "Luo tili ulkoisen palveluntarjoajan kautta",
"noSsoProviders": "Tälle palvelimelle ei ole määritetty SSO-palveluntarjoajia.",
"ssoHint": "Valitse palveluntarjoaja tilin luomiseksi:",
"continueWith": "Jatka palvelulla {provider}",
"validation": {
"handleRequired": "Käyttäjänimi vaaditaan",
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
@@ -275,6 +280,8 @@
"verificationCode": "Vahvistuskoodi",
"verificationCodePlaceholder": "Syötä vahvistuskoodi",
"confirmEmailChange": "Vahvista sähköpostin vaihto",
"emailTokenHint": "Syötä sähköpostissa oleva koodi tai napsauta linkkiä sähköpostissa millä tahansa laitteella.",
"emailUpdateAuthorized": "Sähköpostin vaihto hyväksytty! Napsauta vahvista viimeistelläksesi.",
"updating": "Päivitetään...",
"changeHandle": "Vaihda käyttäjänimi",
"currentHandle": "Nykyinen: @{handle}",
@@ -685,7 +692,21 @@
"passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille",
"passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta",
"passwordPlaceholder": "Syötä salasanasi",
"usePasskey": "Käytä pääsyavainta"
"usePasskey": "Käytä pääsyavainta",
"orContinueWith": "Tai jatka käyttäen",
"orUseCredentials": "Tai kirjaudu tunnuksilla"
},
"sso": {
"linkedAccounts": "Linkitetyt tilit",
"linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.",
"noLinkedAccounts": "Ei linkitettyjä tilejä",
"noLinkedAccountsDesc": "Linkitä ulkoinen tili ottaaksesi käyttöön nopean kirjautumisen kyseisellä palveluntarjoajalla.",
"linkAccount": "Linkitä tili",
"unlinkAccount": "Poista linkitys",
"unlinkConfirm": "Haluatko varmasti poistaa tämän tilin linkityksen?",
"unlinked": "Linkitys poistettu: {provider}",
"lastLoginAt": "Viimeksi käytetty",
"linkedAt": "Linkitetty"
},
"consent": {
"title": "Valtuuta sovellus",
@@ -798,6 +819,18 @@
"backToApp": "Takaisin sovellukseen"
}
},
"sso_register": {
"title": "Viimeistele rekisteröinti",
"subtitle": "Luo tili käyttäen {provider}",
"handle_label": "Valitse käsittelynimi",
"handle_available": "Saatavilla",
"handle_taken": "Jo käytössä",
"submit": "Luo tili",
"error_expired": "Rekisteröintisessio on vanhentunut. Yritä uudelleen.",
"error_handle_required": "Valitse käsittelynimi",
"emailVerifiedByProvider": "Tämä sähköposti on vahvistettu {provider} kautta. Lisävahvistusta ei tarvita.",
"emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se."
},
"verify": {
"title": "Vahvista tilisi",
"subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.",
@@ -831,6 +864,8 @@
"emailUpdateTitle": "Päivitä sähköpostiosoite",
"emailUpdated": "Sähköpostiosoitteesi on päivitetty.",
"emailUpdatedInfo": "Sinun on ehkä vahvistettava uusi sähköpostiosoitteesi.",
"emailAuthorizeSuccess": "Sähköpostipäivityksesi on valtuutettu.",
"emailAuthorizeInfo": "Voit nyt viimeistellä muutoksen alkuperäisellä laitteellasi.",
"newEmailLabel": "Uusi sähköpostiosoite",
"newEmailPlaceholder": "uusi@esimerkki.fi",
"updateEmail": "Päivitä sähköposti",
+36 -1
View File
@@ -163,6 +163,11 @@
"signIn": "サインイン",
"passkeyAccount": "パスキー",
"passwordAccount": "パスワード",
"ssoAccount": "SSO",
"ssoSubtitle": "外部プロバイダーを使用してアカウントを作成",
"noSsoProviders": "このサーバーにはSSOプロバイダーが設定されていません。",
"ssoHint": "プロバイダーを選択してアカウントを作成:",
"continueWith": "{provider}で続行",
"validation": {
"handleRequired": "ハンドルは必須です",
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
@@ -268,6 +273,8 @@
"verificationCode": "確認コード",
"verificationCodePlaceholder": "認証コードを入力",
"confirmEmailChange": "メール変更を確認",
"emailTokenHint": "メールに記載されたコードを入力するか、任意のデバイスでメール内のリンクをクリックしてください。",
"emailUpdateAuthorized": "メール変更が承認されました!確認をクリックして完了してください。",
"updating": "更新中...",
"changeHandle": "ハンドル変更",
"currentHandle": "現在: @{handle}",
@@ -678,7 +685,21 @@
"passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません",
"passkeyHint": "デバイスの生体認証またはセキュリティキーを使用",
"passwordPlaceholder": "パスワードを入力",
"usePasskey": "パスキーを使用"
"usePasskey": "パスキーを使用",
"orContinueWith": "または次の方法で続行",
"orUseCredentials": "または認証情報でサインイン"
},
"sso": {
"linkedAccounts": "連携アカウント",
"linkedAccountsDesc": "シングルサインオン用に連携された外部アカウント。",
"noLinkedAccounts": "連携アカウントなし",
"noLinkedAccountsDesc": "外部アカウントを連携して、そのプロバイダーでのクイックサインインを有効にします。",
"linkAccount": "アカウントを連携",
"unlinkAccount": "連携解除",
"unlinkConfirm": "このアカウントの連携を解除しますか?",
"unlinked": "{provider} の連携を解除しました",
"lastLoginAt": "最終使用",
"linkedAt": "連携日時"
},
"consent": {
"title": "アプリを承認",
@@ -791,6 +812,18 @@
"backToApp": "アプリに戻る"
}
},
"sso_register": {
"title": "登録を完了",
"subtitle": "{provider}でアカウントを作成",
"handle_label": "ハンドルを選択",
"handle_available": "利用可能",
"handle_taken": "既に使用されています",
"submit": "アカウント作成",
"error_expired": "登録セッションが期限切れです。もう一度お試しください。",
"error_handle_required": "ハンドルを選択してください",
"emailVerifiedByProvider": "このメールアドレスは{provider}で確認済みです。追加の確認は不要です。",
"emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。"
},
"verify": {
"title": "アカウント確認",
"subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。",
@@ -824,6 +857,8 @@
"emailUpdateTitle": "メールアドレスの更新",
"emailUpdated": "メールアドレスが正常に更新されました。",
"emailUpdatedInfo": "新しいメールアドレスの確認が必要な場合があります。",
"emailAuthorizeSuccess": "メールアドレスの更新が承認されました。",
"emailAuthorizeInfo": "元のデバイスで変更を完了できます。",
"newEmailLabel": "新しいメールアドレス",
"newEmailPlaceholder": "new@example.com",
"updateEmail": "メールを更新",
+36 -1
View File
@@ -163,6 +163,11 @@
"signIn": "로그인",
"passkeyAccount": "패스키",
"passwordAccount": "비밀번호",
"ssoAccount": "SSO",
"ssoSubtitle": "외부 제공자를 사용하여 계정 만들기",
"noSsoProviders": "이 서버에 SSO 제공자가 설정되어 있지 않습니다.",
"ssoHint": "계정을 만들 제공자를 선택하세요:",
"continueWith": "{provider}로 계속",
"validation": {
"handleRequired": "핸들은 필수입니다",
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
@@ -268,6 +273,8 @@
"verificationCode": "인증 코드",
"verificationCodePlaceholder": "인증 코드 입력",
"confirmEmailChange": "이메일 변경 확인",
"emailTokenHint": "이메일의 코드를 입력하거나 다른 기기에서 이메일의 링크를 클릭하세요.",
"emailUpdateAuthorized": "이메일 변경이 승인되었습니다! 확인을 클릭하여 완료하세요.",
"updating": "업데이트 중...",
"changeHandle": "핸들 변경",
"currentHandle": "현재: @{handle}",
@@ -678,7 +685,21 @@
"passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다",
"passkeyHint": "기기의 생체 인식 또는 보안 키 사용",
"passwordPlaceholder": "비밀번호 입력",
"usePasskey": "패스키 사용"
"usePasskey": "패스키 사용",
"orContinueWith": "또는 다음으로 계속",
"orUseCredentials": "또는 자격 증명으로 로그인"
},
"sso": {
"linkedAccounts": "연결된 계정",
"linkedAccountsDesc": "싱글 사인온을 위해 연결된 외부 계정입니다.",
"noLinkedAccounts": "연결된 계정 없음",
"noLinkedAccountsDesc": "외부 계정을 연결하여 해당 제공자로 빠르게 로그인하세요.",
"linkAccount": "계정 연결",
"unlinkAccount": "연결 해제",
"unlinkConfirm": "이 계정의 연결을 해제하시겠습니까?",
"unlinked": "{provider} 연결 해제됨",
"lastLoginAt": "마지막 사용",
"linkedAt": "연결됨"
},
"consent": {
"title": "앱 승인",
@@ -791,6 +812,18 @@
"backToApp": "앱으로 돌아가기"
}
},
"sso_register": {
"title": "등록 완료",
"subtitle": "{provider}로 계정 생성",
"handle_label": "핸들 선택",
"handle_available": "사용 가능",
"handle_taken": "이미 사용 중",
"submit": "계정 생성",
"error_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.",
"error_handle_required": "핸들을 선택해 주세요",
"emailVerifiedByProvider": "이 이메일은 {provider}에서 인증되었습니다. 추가 인증이 필요하지 않습니다.",
"emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다."
},
"verify": {
"title": "계정 인증",
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.",
@@ -824,6 +857,8 @@
"emailUpdateTitle": "이메일 주소 업데이트",
"emailUpdated": "이메일 주소가 성공적으로 업데이트되었습니다.",
"emailUpdatedInfo": "새 이메일 주소를 인증해야 할 수 있습니다.",
"emailAuthorizeSuccess": "이메일 업데이트가 승인되었습니다.",
"emailAuthorizeInfo": "이제 원래 기기에서 변경을 완료할 수 있습니다.",
"newEmailLabel": "새 이메일 주소",
"newEmailPlaceholder": "new@example.com",
"updateEmail": "이메일 업데이트",
+36 -1
View File
@@ -163,6 +163,11 @@
"signIn": "Logga in",
"passkeyAccount": "Nyckel",
"passwordAccount": "Lösenord",
"ssoAccount": "SSO",
"ssoSubtitle": "Skapa ett konto med en extern leverantör",
"noSsoProviders": "Inga SSO-leverantörer är konfigurerade på denna server.",
"ssoHint": "Välj en leverantör för att skapa ditt konto:",
"continueWith": "Fortsätt med {provider}",
"validation": {
"handleRequired": "Användarnamn krävs",
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
@@ -268,6 +273,8 @@
"verificationCode": "Verifieringskod",
"verificationCodePlaceholder": "Ange verifieringskod",
"confirmEmailChange": "Bekräfta e-poständring",
"emailTokenHint": "Ange koden från e-postmeddelandet, eller klicka på länken i e-postmeddelandet på valfri enhet.",
"emailUpdateAuthorized": "E-poständring godkänd! Klicka på bekräfta för att slutföra.",
"updating": "Uppdaterar...",
"changeHandle": "Ändra användarnamn",
"currentHandle": "Nuvarande: @{handle}",
@@ -678,7 +685,21 @@
"passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto",
"passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel",
"passwordPlaceholder": "Ange ditt lösenord",
"usePasskey": "Använd nyckel"
"usePasskey": "Använd nyckel",
"orContinueWith": "Eller fortsätt med",
"orUseCredentials": "Eller logga in med uppgifter"
},
"sso": {
"linkedAccounts": "Länkade konton",
"linkedAccountsDesc": "Externa konton länkade till din identitet för enkel inloggning.",
"noLinkedAccounts": "Inga länkade konton",
"noLinkedAccountsDesc": "Länka ett externt konto för att aktivera snabb inloggning med den leverantören.",
"linkAccount": "Länka konto",
"unlinkAccount": "Ta bort länk",
"unlinkConfirm": "Är du säker på att du vill ta bort länken till detta konto?",
"unlinked": "Länk till {provider} borttagen",
"lastLoginAt": "Senast använd",
"linkedAt": "Länkad"
},
"consent": {
"title": "Auktorisera applikation",
@@ -791,6 +812,18 @@
"backToApp": "Tillbaka till applikationen"
}
},
"sso_register": {
"title": "Slutför registrering",
"subtitle": "Skapar konto med {provider}",
"handle_label": "Välj ditt användarnamn",
"handle_available": "Tillgängligt",
"handle_taken": "Redan taget",
"submit": "Skapa konto",
"error_expired": "Registreringssessionen har löpt ut. Försök igen.",
"error_handle_required": "Välj ett användarnamn",
"emailVerifiedByProvider": "Denna e-post är verifierad av {provider}. Ingen ytterligare verifiering behövs.",
"emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den."
},
"verify": {
"title": "Verifiera ditt konto",
"subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.",
@@ -824,6 +857,8 @@
"emailUpdateTitle": "Uppdatera e-postadress",
"emailUpdated": "Din e-postadress har uppdaterats.",
"emailUpdatedInfo": "Du kan behöva verifiera din nya e-postadress.",
"emailAuthorizeSuccess": "Din e-postuppdatering har auktoriserats.",
"emailAuthorizeInfo": "Du kan nu slutföra ändringen på din ursprungliga enhet.",
"newEmailLabel": "Ny e-postadress",
"newEmailPlaceholder": "ny@exempel.se",
"updateEmail": "Uppdatera e-post",
+36 -1
View File
@@ -163,6 +163,11 @@
"signIn": "立即登录",
"passkeyAccount": "通行密钥",
"passwordAccount": "密码",
"ssoAccount": "SSO",
"ssoSubtitle": "使用外部提供商创建账户",
"noSsoProviders": "此服务器未配置SSO提供商。",
"ssoHint": "选择一个提供商来创建您的账户:",
"continueWith": "使用{provider}继续",
"validation": {
"handleRequired": "请输入用户名",
"handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。",
@@ -268,6 +273,8 @@
"verificationCode": "验证码",
"verificationCodePlaceholder": "输入验证码",
"confirmEmailChange": "确认更改邮箱",
"emailTokenHint": "输入邮件中的验证码,或在任意设备上点击邮件中的链接。",
"emailUpdateAuthorized": "邮箱更改已授权!点击确认完成。",
"updating": "更新中...",
"changeHandle": "更改用户名",
"currentHandle": "当前:@{handle}",
@@ -678,7 +685,21 @@
"passkeyHintNotAvailable": "此账户未注册通行密钥",
"passkeyHint": "使用设备的生物识别或安全密钥",
"passwordPlaceholder": "输入您的密码",
"usePasskey": "使用通行密钥"
"usePasskey": "使用通行密钥",
"orContinueWith": "或使用以下方式继续",
"orUseCredentials": "或使用凭证登录"
},
"sso": {
"linkedAccounts": "已关联账户",
"linkedAccountsDesc": "已关联到您身份的外部账户,用于单点登录。",
"noLinkedAccounts": "暂无关联账户",
"noLinkedAccountsDesc": "关联外部账户以启用该服务商的快速登录。",
"linkAccount": "关联账户",
"unlinkAccount": "取消关联",
"unlinkConfirm": "确定要取消关联此账户吗?",
"unlinked": "已取消关联 {provider}",
"lastLoginAt": "上次使用",
"linkedAt": "关联时间"
},
"consent": {
"title": "授权应用",
@@ -791,6 +812,18 @@
"backToApp": "返回应用"
}
},
"sso_register": {
"title": "完成注册",
"subtitle": "使用{provider}创建账户",
"handle_label": "选择您的昵称",
"handle_available": "可用",
"handle_taken": "已被使用",
"submit": "创建账户",
"error_expired": "注册会话已过期。请重试。",
"error_handle_required": "请选择一个昵称",
"emailVerifiedByProvider": "此邮箱已由{provider}验证。无需额外验证。",
"emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。"
},
"verify": {
"title": "验证账户",
"subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。",
@@ -824,6 +857,8 @@
"emailUpdateTitle": "更新邮箱地址",
"emailUpdated": "您的邮箱地址已成功更新。",
"emailUpdatedInfo": "您可能需要验证新的邮箱地址。",
"emailAuthorizeSuccess": "您的邮箱更新已授权。",
"emailAuthorizeInfo": "您现在可以在原设备上完成更改。",
"newEmailLabel": "新邮箱地址",
"newEmailPlaceholder": "new@example.com",
"updateEmail": "更新邮箱",
+263 -55
View File
@@ -7,8 +7,17 @@
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
import SsoIcon from '../components/SsoIcon.svelte'
interface SsoProvider {
provider: string
name: string
icon: string
}
let username = $state('')
let ssoProviders = $state<SsoProvider[]>([])
let ssoLoading = $state<string | null>(null)
let password = $state('')
let rememberDevice = $state(false)
let submitting = $state(false)
@@ -46,8 +55,66 @@
$effect(() => {
fetchAuthRequestInfo()
fetchSsoProviders()
})
async function fetchSsoProviders() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoProviders = data.providers || []
}
} catch {
ssoProviders = []
}
}
async function handleSsoLogin(provider: string) {
const requestUri = getRequestUri()
if (!requestUri) {
error = $_('common.error')
return
}
ssoLoading = provider
error = null
try {
const response = await fetch('/oauth/sso/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
provider,
request_uri: requestUri,
action: 'login'
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || 'Failed to start SSO login'
ssoLoading = null
return
}
if (data.redirect_url) {
window.location.href = data.redirect_url
return
}
error = $_('common.error')
ssoLoading = null
} catch {
error = $_('common.error')
ssoLoading = null
}
}
async function fetchAuthRequestInfo() {
const requestUri = getRequestUri()
if (!requestUri) return
@@ -328,8 +395,33 @@
/>
</div>
{#if ssoProviders.length > 0}
<div class="sso-section sso-section-top">
<div class="sso-buttons">
{#each ssoProviders as provider}
<button
type="button"
class="sso-btn sso-btn-prominent"
onclick={() => handleSsoLogin(provider.provider)}
disabled={submitting || ssoLoading !== null}
>
{#if ssoLoading === provider.provider}
<span class="loading-spinner"></span>
{:else}
<SsoIcon provider={provider.icon} size={20} />
{/if}
<span>{provider.name}</span>
</button>
{/each}
</div>
<div class="sso-divider">
<span>{$_('oauth.login.orUseCredentials')}</span>
</div>
</div>
{/if}
{#if passkeySupported && username.length >= 3}
<div class="auth-methods">
<div class="auth-methods" class:single-method={!hasPassword}>
<div class="passkey-method">
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
<button
@@ -360,65 +452,72 @@
<p class="method-hint">{$_('oauth.login.passkeyHint')}</p>
</div>
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
{#if hasPassword}
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</div>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
<div class="cancel-row">
<button type="button" class="cancel-btn-subtle" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
</div>
{:else}
<div class="field">
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
{#if hasPassword || !securityStatusChecked}
<div class="field">
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
<div class="actions">
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="cancel-row">
<button type="button" class="cancel-btn-subtle" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</form>
@@ -623,16 +722,30 @@
cursor: not-allowed;
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
.cancel-row {
display: flex;
justify-content: center;
margin-top: var(--space-4);
}
.cancel-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
.cancel-btn-subtle {
padding: var(--space-2) var(--space-4);
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
.cancel-btn-subtle:hover:not(:disabled) {
color: var(--text-secondary);
}
.cancel-btn-subtle:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.submit-btn {
@@ -686,4 +799,99 @@
flex: 1;
text-align: left;
}
.sso-section {
margin-top: var(--space-6);
}
.sso-section-top {
margin-top: var(--space-4);
margin-bottom: 0;
}
.sso-section-top .sso-divider {
margin-top: var(--space-5);
margin-bottom: 0;
}
.sso-divider {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
color: var(--text-muted);
font-size: var(--text-sm);
}
.sso-divider::before,
.sso-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
.sso-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: center;
}
.sso-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.sso-btn-prominent {
padding: var(--space-3) var(--space-5);
font-size: var(--text-base);
font-weight: var(--font-medium);
}
.sso-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--accent);
}
.sso-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-methods.single-method {
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px 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>
+614
View File
@@ -0,0 +1,614 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
interface PendingRegistration {
request_uri: string
provider: string
provider_user_id: string
provider_username: string | null
provider_email: string | null
provider_email_verified: boolean
}
interface CommsChannelConfig {
email: boolean
discord: boolean
telegram: boolean
signal: boolean
}
let pending = $state<PendingRegistration | null>(null)
let loading = $state(true)
let submitting = $state(false)
let error = $state<string | null>(null)
let handle = $state('')
let email = $state('')
let providerEmailOriginal = $state<string | null>(null)
let inviteCode = $state('')
let verificationChannel = $state('email')
let discordId = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
} | null>(null)
let commsChannels = $state<CommsChannelConfig>({
email: true,
discord: false,
telegram: false,
signal: false,
})
function getToken(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('token')
}
function getProviderDisplayName(provider: string): string {
const names: Record<string, string> = {
github: 'GitHub',
discord: 'Discord',
google: 'Google',
gitlab: 'GitLab',
oidc: 'SSO',
}
return names[provider] || provider
}
function isChannelAvailable(ch: string): boolean {
return commsChannels[ch as keyof CommsChannelConfig] ?? false
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
const domain = serverInfo?.availableUserDomains?.[0]
return domain ? `${handle.trim()}.${domain}` : handle.trim()
})
onMount(() => {
loadPendingRegistration()
loadServerInfo()
})
async function loadServerInfo() {
try {
const response = await fetch('/xrpc/com.atproto.server.describeServer')
if (response.ok) {
const data = await response.json()
serverInfo = {
availableUserDomains: data.availableUserDomains || [],
inviteCodeRequired: data.inviteCodeRequired ?? false,
}
if (data.commsChannels) {
commsChannels = {
email: data.commsChannels.email ?? true,
discord: data.commsChannels.discord ?? false,
telegram: data.commsChannels.telegram ?? false,
signal: data.commsChannels.signal ?? false,
}
}
}
} catch {
serverInfo = null
}
}
async function loadPendingRegistration() {
const token = getToken()
if (!token) {
error = $_('sso_register.error_expired')
loading = false
return
}
try {
const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
if (!response.ok) {
const data = await response.json()
error = data.message || $_('sso_register.error_expired')
loading = false
return
}
pending = await response.json()
if (pending?.provider_email) {
email = pending.provider_email
providerEmailOriginal = pending.provider_email
}
if (pending?.provider_username) {
handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
}
} catch {
error = $_('sso_register.error_expired')
} finally {
loading = false
}
}
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
handleAvailable = null
handleError = null
if (handle.length >= 3) {
checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
}
})
async function checkHandleAvailability() {
if (!handle || handle.length < 3) return
checkingHandle = true
handleError = null
try {
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
const data = await response.json()
handleAvailable = data.available
if (!data.available && data.reason) {
handleError = data.reason
}
} catch {
handleAvailable = null
handleError = $_('common.error')
} finally {
checkingHandle = false
}
}
let usingVerifiedProviderEmail = $derived(
pending?.provider_email_verified &&
verificationChannel === 'email' &&
email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
)
function isChannelValid(): boolean {
switch (verificationChannel) {
case 'email':
return !!email.trim()
case 'discord':
return !!discordId.trim()
case 'telegram':
return !!telegramUsername.trim()
case 'signal':
return !!signalNumber.trim()
default:
return false
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const token = getToken()
if (!token || !pending) return
if (!handle || handle.length < 3) {
handleError = $_('sso_register.error_handle_required')
return
}
if (handleAvailable === false) {
handleError = $_('sso_register.handle_taken')
return
}
if (!isChannelValid()) {
toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
return
}
submitting = true
try {
const response = await fetch('/oauth/sso/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token,
handle,
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
discord_id: discordId || null,
telegram_username: telegramUsername || null,
signal_number: signalNumber || null,
}),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.message || data.error_description || data.error || $_('common.error'))
submitting = false
return
}
if (data.accessJwt && data.refreshJwt) {
localStorage.setItem('accessJwt', data.accessJwt)
localStorage.setItem('refreshJwt', data.refreshJwt)
}
if (data.redirectUrl) {
if (data.redirectUrl.startsWith('/app/verify')) {
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
did: data.did,
handle: data.handle,
channel: verificationChannel,
}))
}
window.location.href = data.redirectUrl
return
}
toast.error($_('common.error'))
submitting = false
} catch {
toast.error($_('common.error'))
submitting = false
}
}
</script>
<div class="sso-register-container">
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>{$_('common.loading')}</p>
</div>
{:else if error && !pending}
<div class="error-container">
<div class="error-icon">!</div>
<h2>{$_('common.error')}</h2>
<p>{error}</p>
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
</div>
{:else if pending}
<header class="page-header">
<h1>{$_('sso_register.title')}</h1>
<p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
</header>
<div class="provider-info">
<div class="provider-badge">
<SsoIcon provider={pending.provider} size={32} />
<div class="provider-details">
<span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
{#if pending.provider_username}
<span class="provider-username">@{pending.provider_username}</span>
{/if}
</div>
</div>
</div>
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleSubmit}>
<div class="field">
<label for="handle">{$_('sso_register.handle_label')}</label>
<input
id="handle"
type="text"
bind:value={handle}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
required
autocomplete="off"
/>
{#if checkingHandle}
<p class="hint">{$_('common.checking')}</p>
{:else if handleError}
<p class="hint error">{handleError}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('sso_register.handle_taken')}</p>
{:else if handleAvailable === true}
<p class="hint success">{$_('sso_register.handle_available')}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset>
<legend>{$_('register.contactMethod')}</legend>
<div class="contact-fields">
<div class="field">
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={email}
placeholder={$_('register.emailPlaceholder')}
disabled={submitting}
required
/>
{#if pending?.provider_email && pending?.provider_email_verified}
{#if usingVerifiedProviderEmail}
<p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
{:else}
<p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
{/if}
{/if}
</div>
{:else if verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input
id="discord-id"
type="text"
bind:value={discordId}
placeholder={$_('register.discordIdPlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={submitting}
required
/>
</div>
{:else if verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
</div>
{/if}
</div>
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
<input
id="invite-code"
type="text"
bind:value={inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={submitting}
required
/>
</div>
{/if}
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
{submitting ? $_('common.creating') : $_('sso_register.submit')}
</button>
</form>
</div>
<aside class="info-panel">
<h3>{$_('sso_register.infoAfterTitle')}</h3>
<ul class="info-list">
<li>{$_('sso_register.infoAddPassword')}</li>
<li>{$_('sso_register.infoAddPasskey')}</li>
<li>{$_('sso_register.infoLinkProviders')}</li>
<li>{$_('sso_register.infoChangeHandle')}</li>
</ul>
</aside>
</div>
{/if}
</div>
<style>
.sso-register-container {
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-8);
}
.loading p {
color: var(--text-secondary);
}
.error-container {
text-align: center;
padding: var(--space-8);
}
.error-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--error-text);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin: 0 auto var(--space-4);
}
.error-container h2 {
margin-bottom: var(--space-2);
}
.error-container p {
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.back-link {
color: var(--accent);
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h1 {
margin: 0 0 var(--space-3) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.form-section {
min-width: 0;
}
form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.contact-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.contact-fields .field {
margin-bottom: 0;
}
.hint.success {
color: var(--success-text);
}
.hint.error {
color: var(--error-text);
}
.info-panel {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: var(--space-6);
}
.info-panel h3 {
margin: 0 0 var(--space-4) 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
}
.info-list {
margin: 0;
padding-left: var(--space-5);
}
.info-list li {
margin-bottom: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.info-list li:last-child {
margin-bottom: 0;
}
.provider-info {
margin-bottom: var(--space-6);
}
.provider-badge {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.provider-details {
display: flex;
flex-direction: column;
}
.provider-name {
font-weight: var(--font-semibold);
}
.provider-username {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.required {
color: var(--error-text);
}
button[type="submit"] {
margin-top: var(--space-3);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+15 -1
View File
@@ -19,6 +19,7 @@
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
let ssoAvailable = $state(false)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let confirmPassword = $state('')
@@ -27,9 +28,22 @@
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
checkSsoAvailable()
}
})
async function checkSsoAvailable() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoAvailable = (data.providers?.length ?? 0) > 0
}
} catch {
ssoAvailable = false
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate(routes.dashboard)
@@ -187,7 +201,7 @@
</div>
</div>
<AccountTypeSwitcher active="password" />
<AccountTypeSwitcher active="password" {ssoAvailable} />
<div class="split-layout sidebar-right">
<div class="form-section">
+15 -1
View File
@@ -25,6 +25,7 @@
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
let ssoAvailable = $state(false)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
@@ -33,9 +34,22 @@
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
checkSsoAvailable()
}
})
async function checkSsoAvailable() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoAvailable = (data.providers?.length ?? 0) > 0
}
} catch {
ssoAvailable = false
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate('/dashboard')
@@ -247,7 +261,7 @@
</div>
</div>
<AccountTypeSwitcher active="passkey" />
<AccountTypeSwitcher active="passkey" {ssoAvailable} />
<div class="split-layout sidebar-right">
<div class="form-section">
+293
View File
@@ -0,0 +1,293 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { getFullUrl } from '../lib/router.svelte'
import { routes } from '../lib/types/routes'
import { toast } from '../lib/toast.svelte'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
interface SsoProvider {
provider: string
name: string
icon: string
}
let providers = $state<SsoProvider[]>([])
let loading = $state(true)
let initiating = $state<string | null>(null)
onMount(() => {
fetchProviders()
})
async function fetchProviders() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
providers = data.providers || []
}
} catch {
toast.error($_('common.error'))
} finally {
loading = false
}
}
async function initiateRegistration(provider: string) {
initiating = provider
try {
const response = await fetch('/oauth/sso/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
provider,
action: 'register',
}),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.error_description || data.error || $_('common.error'))
initiating = null
return
}
if (data.redirect_url) {
window.location.href = data.redirect_url
return
}
toast.error($_('common.error'))
initiating = null
} catch {
toast.error($_('common.error'))
initiating = null
}
}
</script>
<div class="register-sso-page">
<header class="page-header">
<h1>{$_('register.title')}</h1>
<p class="subtitle">{$_('register.ssoSubtitle')}</p>
</header>
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href={getFullUrl(routes.migrate)} class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
<AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} />
{#if loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else if providers.length === 0}
<div class="no-providers">
<p>{$_('register.noSsoProviders')}</p>
</div>
{:else}
<div class="provider-list">
<p class="provider-hint">{$_('register.ssoHint')}</p>
<div class="provider-grid">
{#each providers as provider}
<button
class="provider-button"
onclick={() => initiateRegistration(provider.provider)}
disabled={initiating !== null}
>
<SsoIcon provider={provider.provider} size={24} />
<span class="provider-name">
{#if initiating === provider.provider}
{$_('common.loading')}
{:else}
{$_('register.continueWith', { values: { provider: provider.name } })}
{/if}
</span>
</button>
{/each}
</div>
</div>
{/if}
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
</p>
</div>
</div>
<style>
.register-sso-page {
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h1 {
margin: 0 0 var(--space-3) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
.loading {
display: flex;
justify-content: center;
padding: var(--space-8);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-providers {
text-align: center;
padding: var(--space-8);
color: var(--text-secondary);
}
.provider-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: var(--width-md);
}
.provider-hint {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0 0 var(--space-4) 0;
}
.provider-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
}
@media (min-width: 500px) {
.provider-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.provider-button {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg-card);
border: 1px solid var(--border-dark);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-normal);
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--text-primary);
text-align: left;
width: 100%;
}
.provider-button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.provider-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.provider-name {
flex: 1;
}
.form-links {
margin-top: var(--space-8);
}
.link-text {
text-align: center;
color: var(--text-secondary);
}
.link-text a {
color: var(--accent);
}
</style>
+327
View File
@@ -3,6 +3,7 @@
import { navigate, 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'
@@ -13,6 +14,22 @@
} from '../lib/webauthn'
import { toast } from '../lib/toast.svelte'
interface SsoProvider {
provider: string
name: string
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 {
@@ -69,6 +86,12 @@
let legacyLoginLoading = $state(true)
let legacyLoginUpdating = $state(false)
let ssoProviders = $state<SsoProvider[]>([])
let linkedAccounts = $state<LinkedAccount[]>([])
let linkedAccountsLoading = $state(true)
let linkingProvider = $state<string | null>(null)
let unlinkingId = $state<string | null>(null)
let showReauthModal = $state(false)
let reauthMethods = $state<string[]>(['password'])
let pendingAction = $state<(() => Promise<void>) | null>(null)
@@ -85,9 +108,125 @@
loadPasskeys()
loadPasswordStatus()
loadLegacyLoginPreference()
loadSsoProviders()
loadLinkedAccounts()
}
})
async function loadSsoProviders() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoProviders = data.providers || []
}
} catch {
ssoProviders = []
}
}
async function loadLinkedAccounts() {
if (!session) 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 || []
}
} catch {
linkedAccounts = []
} finally {
linkedAccountsLoading = false
}
}
async function handleLinkAccount(provider: string) {
linkingProvider = provider
const linkRequestUri = `urn:tranquil:sso:link:${Date.now()}`
try {
const response = await fetch('/oauth/sso/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
},
body: JSON.stringify({
provider,
request_uri: linkRequestUri,
action: 'link'
})
})
const data = await response.json()
if (!response.ok) {
if (data.error === 'ReauthRequired') {
reauthMethods = data.reauthMethods || ['password']
pendingAction = () => handleLinkAccount(provider)
showReauthModal = true
} else {
toast.error(data.error_description || data.error || 'Failed to start SSO linking')
}
linkingProvider = null
return
}
if (data.redirect_url) {
window.location.href = data.redirect_url
return
}
toast.error($_('common.error'))
linkingProvider = null
} catch {
toast.error($_('common.error'))
linkingProvider = null
}
}
async function handleUnlinkAccount(id: string) {
const account = linkedAccounts.find(a => a.id === id)
if (!confirm($_('oauth.sso.unlinkConfirm'))) return
unlinkingId = id
try {
const response = await fetch('/oauth/sso/unlink', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
},
body: JSON.stringify({ id })
})
if (!response.ok) {
const data = await response.json()
if (data.error === 'ReauthRequired') {
reauthMethods = data.reauthMethods || ['password']
pendingAction = () => handleUnlinkAccount(id)
showReauthModal = true
} else {
toast.error(data.error_description || data.error || 'Failed to unlink account')
}
unlinkingId = null
return
}
await loadLinkedAccounts()
toast.success($_('oauth.sso.unlinked', { values: { provider: account?.provider_name || 'account' } }))
} catch {
toast.error($_('common.error'))
} finally {
unlinkingId = null
}
}
async function loadPasswordStatus() {
if (!session) return
passwordLoading = true
@@ -696,6 +835,80 @@
{$_('security.manageTrustedDevices')} &rarr;
</a>
</section>
{#if ssoProviders.length > 0}
<section>
<h2>{$_('oauth.sso.linkedAccounts')}</h2>
<p class="description">
{$_('oauth.sso.linkedAccountsDesc')}
</p>
{#if !linkedAccountsLoading}
{#if linkedAccounts.length > 0}
<div class="linked-accounts-list">
{#each linkedAccounts as account}
<div class="linked-account-item">
<div class="linked-account-icon">
<SsoIcon provider={account.provider} size={24} />
</div>
<div class="linked-account-info">
<span class="linked-account-provider">{account.provider_name}</span>
<span class="linked-account-meta">
{#if account.provider_username}
{account.provider_username}
{:else if account.provider_email}
{account.provider_email}
{/if}
{#if account.last_login_at}
&middot; {$_('oauth.sso.lastLoginAt')} {formatDate(account.last_login_at)}
{/if}
</span>
</div>
<button
type="button"
class="small danger-outline"
onclick={() => handleUnlinkAccount(account.id)}
disabled={unlinkingId !== null}
>
{unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlinkAccount')}
</button>
</div>
{/each}
</div>
{:else}
<div class="status disabled">
<span>{$_('oauth.sso.noLinkedAccounts')}</span>
</div>
<p class="hint">{$_('oauth.sso.noLinkedAccountsDesc')}</p>
{/if}
{#if ssoProviders.some(p => !linkedAccounts.some(a => a.provider === p.provider))}
<div class="link-account-section">
<h3>{$_('oauth.sso.linkAccount')}</h3>
<div class="sso-link-buttons">
{#each ssoProviders.filter(p => !linkedAccounts.some(a => a.provider === p.provider)) as provider}
<button
type="button"
class="sso-link-btn"
onclick={() => handleLinkAccount(provider.provider)}
disabled={linkingProvider !== null}
>
{#if linkingProvider === provider.provider}
<span class="loading-spinner small"></span>
{:else}
<SsoIcon provider={provider.icon} size={18} />
{/if}
<span>{provider.name}</span>
</button>
{/each}
</div>
</div>
{/if}
{:else}
<div class="loading-text">{$_('common.loading')}</div>
{/if}
</section>
{/if}
</div>
{#if hasMfa}
@@ -1208,4 +1421,118 @@
grid-template-columns: 1fr;
}
}
.linked-accounts-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.linked-account-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
}
.linked-account-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.linked-account-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.linked-account-provider {
font-weight: var(--font-medium);
}
.linked-account-meta {
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-account-section {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border-color);
}
.link-account-section h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.sso-link-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.sso-link-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.sso-link-btn:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.sso-link-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner.small {
width: 18px;
height: 18px;
border-width: 2px;
}
.loading-spinner {
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);
}
}
.loading-text {
color: var(--text-secondary);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-4);
}
</style>
+85 -20
View File
@@ -31,6 +31,10 @@
pdsHostname = info.availableUserDomains[0]
}
}).catch(() => {})
return () => {
stopEmailPolling()
}
})
let localeLoading = $state(false)
@@ -51,6 +55,8 @@
let newEmail = $state('')
let emailToken = $state('')
let emailTokenRequired = $state(false)
let emailUpdateAuthorized = $state(false)
let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null)
let handleLoading = $state(false)
let newHandle = $state('')
let deleteLoading = $state(false)
@@ -97,13 +103,14 @@
}
async function handleRequestEmailUpdate() {
if (!session) return
if (!session || !newEmail.trim()) return
emailLoading = true
try {
const result = await api.requestEmailUpdate(session.accessJwt)
const result = await api.requestEmailUpdate(session.accessJwt, newEmail.trim())
emailTokenRequired = result.tokenRequired
if (emailTokenRequired) {
toast.success($_('settings.messages.emailCodeSentToCurrent'))
startEmailPolling()
} else {
emailTokenRequired = true
}
@@ -114,6 +121,47 @@
}
}
function startEmailPolling() {
if (emailPollingInterval) return
emailPollingInterval = setInterval(async () => {
if (!session) return
try {
const status = await api.checkEmailUpdateStatus(session.accessJwt)
if (status.authorized) {
emailUpdateAuthorized = true
stopEmailPolling()
await completeAuthorizedEmailUpdate()
}
} catch {
}
}, 3000)
}
function stopEmailPolling() {
if (emailPollingInterval) {
clearInterval(emailPollingInterval)
emailPollingInterval = null
}
}
async function completeAuthorizedEmailUpdate() {
if (!session || !newEmail.trim()) return
emailLoading = true
try {
await api.updateEmail(session.accessJwt, newEmail.trim())
await refreshSession()
toast.success($_('settings.messages.emailUpdated'))
newEmail = ''
emailToken = ''
emailTokenRequired = false
emailUpdateAuthorized = false
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
} finally {
emailLoading = false
}
}
async function handleConfirmEmailUpdate(e: Event) {
e.preventDefault()
if (!session || !newEmail || !emailToken) return
@@ -474,17 +522,43 @@
{/if}
{#if emailTokenRequired}
<form onsubmit={handleConfirmEmailUpdate}>
{#if emailUpdateAuthorized}
<p class="hint success">{$_('settings.emailUpdateAuthorized')}</p>
{:else}
<div class="field">
<label for="email-token">{$_('settings.verificationCode')}</label>
<input
id="email-token"
type="text"
bind:value={emailToken}
placeholder={$_('settings.verificationCodePlaceholder')}
disabled={emailLoading}
/>
<p class="hint">{$_('settings.emailTokenHint')}</p>
</div>
{/if}
<div class="field">
<label for="email-token">{$_('settings.verificationCode')}</label>
<label for="new-email">{$_('settings.newEmail')}</label>
<input
id="email-token"
type="text"
bind:value={emailToken}
placeholder={$_('settings.verificationCodePlaceholder')}
disabled={emailLoading}
id="new-email"
type="email"
bind:value={newEmail}
placeholder={$_('settings.newEmailPlaceholder')}
disabled={emailLoading || emailUpdateAuthorized}
required
/>
</div>
<div class="actions">
<button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}>
{emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
</button>
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = ''; emailUpdateAuthorized = false; stopEmailPolling() }}>
{$_('common.cancel')}
</button>
</div>
</form>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleRequestEmailUpdate() }}>
<div class="field">
<label for="new-email">{$_('settings.newEmail')}</label>
<input
@@ -496,19 +570,10 @@
required
/>
</div>
<div class="actions">
<button type="submit" disabled={emailLoading || !emailToken || !newEmail}>
{emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
</button>
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = '' }}>
{$_('common.cancel')}
</button>
</div>
<button type="submit" disabled={emailLoading || !newEmail.trim()}>
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
</button>
</form>
{:else}
<button onclick={handleRequestEmailUpdate} disabled={emailLoading}>
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
</button>
{/if}
</section>
<section>
+28 -17
View File
@@ -15,7 +15,7 @@
channel: string
}
type VerificationMode = 'signup' | 'token' | 'email-update'
type VerificationMode = 'signup' | 'token' | 'email-update' | 'email-authorize-success'
let mode = $state<VerificationMode>('signup')
let newEmail = $state('')
@@ -30,6 +30,7 @@
let autoSubmitting = $state(false)
let successPurpose = $state<string | null>(null)
let successChannel = $state<string | null>(null)
let tokenFromUrl = $state(false)
const auth = $derived(getAuthState())
@@ -46,10 +47,15 @@
onMount(async () => {
const params = parseQueryParams()
if (params.type === 'email-update') {
if (params.type === 'email-authorize-success') {
mode = 'email-authorize-success'
success = true
successPurpose = 'email-authorize'
} else if (params.type === 'email-update') {
mode = 'email-update'
if (params.token) {
verificationCode = params.token
tokenFromUrl = true
}
} else if (params.token) {
mode = 'token'
@@ -231,7 +237,10 @@
{:else if success}
<div class="success-container">
<h1>{$_('verify.verified')}</h1>
{#if successPurpose === 'email-update'}
{#if successPurpose === 'email-authorize'}
<p class="subtitle">{$_('verify.emailAuthorizeSuccess')}</p>
<p class="info-text">{$_('verify.emailAuthorizeInfo')}</p>
{:else if successPurpose === 'email-update'}
<p class="subtitle">{$_('verify.emailUpdated')}</p>
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
<div class="actions">
@@ -283,20 +292,22 @@
/>
</div>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p>
</div>
{#if !tokenFromUrl}
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.emailUpdateCodeHelp')}</p>
</div>
{/if}
<button type="submit" disabled={submitting || !verificationCode.trim() || !newEmail.trim()}>
{submitting ? $_('verify.updating') : $_('verify.updateEmail')}
+5 -2
View File
@@ -61,7 +61,8 @@ describe("AppPasswords", () => {
),
);
const { container } = render(AppPasswords);
expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0);
expect(container.querySelectorAll(".skeleton-item").length)
.toBeGreaterThan(0);
});
});
describe("empty state", () => {
@@ -392,7 +393,9 @@ describe("AppPasswords", () => {
render(AppPasswords);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(
true,
);
});
});
});
+11 -5
View File
@@ -12,7 +12,7 @@ import {
setupAuthenticatedUser,
setupDefaultMocks,
setupUnauthenticatedUser,
} from "./mocks";
} from "./mocks.ts";
describe("Comms", () => {
beforeEach(() => {
clearMocks();
@@ -85,7 +85,8 @@ describe("Comms", () => {
),
);
const { container } = render(Comms);
expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0);
expect(container.querySelectorAll(".skeleton-section").length)
.toBeGreaterThan(0);
});
});
describe("channel options", () => {
@@ -375,7 +376,9 @@ describe("Comms", () => {
);
await waitFor(() => {
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true);
expect(
toasts.some((t) => t.type === "success" && /saved/i.test(t.message)),
).toBe(true);
});
});
it("shows error toast when save fails", async () => {
@@ -398,7 +401,8 @@ describe("Comms", () => {
);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true);
expect(errors.some((e) => /invalid channel configuration/i.test(e)))
.toBe(true);
});
});
it("reloads preferences after successful save", async () => {
@@ -495,7 +499,9 @@ describe("Comms", () => {
render(Comms);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(
true,
);
});
});
});
+3 -2
View File
@@ -9,7 +9,7 @@ import {
setupAuthenticatedUser,
setupFetchMock,
setupUnauthenticatedUser,
} from "./mocks";
} from "./mocks.ts";
const STORAGE_KEY = "tranquil_pds_session";
describe("Dashboard", () => {
beforeEach(() => {
@@ -27,7 +27,8 @@ describe("Dashboard", () => {
it("shows loading state while checking auth", () => {
const { container } = render(Dashboard);
expect(container.querySelector(".skeleton-section")).toBeInTheDocument();
expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0);
expect(container.querySelectorAll(".skeleton-card").length)
.toBeGreaterThan(0);
});
});
describe("authenticated view", () => {
+63 -26
View File
@@ -12,7 +12,7 @@ import {
setupAuthenticatedUser,
setupDefaultMocks,
setupUnauthenticatedUser,
} from "./mocks";
} from "./mocks.ts";
describe("Settings", () => {
beforeEach(() => {
clearMocks();
@@ -68,10 +68,16 @@ describe("Settings", () => {
requestCalled = true;
return jsonResponse({ tokenRequired: true });
});
mockEndpoint(
"_account.checkEmailUpdateStatus",
() => jsonResponse({ pending: false, authorized: false }),
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "newemail@example.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
@@ -85,10 +91,16 @@ describe("Settings", () => {
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
);
mockEndpoint(
"_account.checkEmailUpdateStatus",
() => jsonResponse({ pending: false, authorized: false }),
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "newemail@example.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
@@ -107,6 +119,10 @@ describe("Settings", () => {
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
);
mockEndpoint(
"_account.checkEmailUpdateStatus",
() => jsonResponse({ pending: false, authorized: false }),
);
mockEndpoint("com.atproto.server.updateEmail", (_url, options) => {
updateCalled = true;
capturedBody = JSON.parse((options?.body as string) || "{}");
@@ -118,8 +134,10 @@ describe("Settings", () => {
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "newemail@example.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
@@ -130,9 +148,6 @@ describe("Settings", () => {
await fireEvent.input(screen.getByLabelText(/verification code/i), {
target: { value: "123456" },
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "newemail@example.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /confirm email change/i }),
);
@@ -147,6 +162,10 @@ describe("Settings", () => {
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
);
mockEndpoint(
"_account.checkEmailUpdateStatus",
() => jsonResponse({ pending: false, authorized: false }),
);
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.getSession",
@@ -154,8 +173,10 @@ describe("Settings", () => {
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "new@test.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
@@ -166,15 +187,16 @@ describe("Settings", () => {
await fireEvent.input(screen.getByLabelText(/verification code/i), {
target: { value: "123456" },
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "new@test.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /confirm email change/i }),
);
await waitFor(() => {
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true);
expect(
toasts.some((t) =>
t.type === "success" && /email.*updated/i.test(t.message)
),
).toBe(true);
});
});
it("shows cancel button to return to initial state", async () => {
@@ -182,10 +204,16 @@ describe("Settings", () => {
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
);
mockEndpoint(
"_account.checkEmailUpdateStatus",
() => jsonResponse({ pending: false, authorized: false }),
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "newemail@example.com" },
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
@@ -214,12 +242,13 @@ describe("Settings", () => {
);
render(Settings);
await waitFor(() => {
expect(screen.getByRole("button", { name: /change email/i }))
.toBeInTheDocument();
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
});
await fireEvent.click(
screen.getByRole("button", { name: /change email/i }),
);
await fireEvent.input(screen.getByLabelText(/new email/i), {
target: { value: "invalid@email.com" },
});
const button = screen.getByRole("button", { name: /change email/i });
await fireEvent.submit(button.closest("form")!);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true);
@@ -283,7 +312,11 @@ describe("Settings", () => {
await fireEvent.submit(button.closest("form")!);
await waitFor(() => {
const toasts = getToasts();
expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true);
expect(
toasts.some((t) =>
t.type === "success" && /handle.*updated/i.test(t.message)
),
).toBe(true);
});
});
it("shows error toast when handle change fails", async () => {
@@ -306,7 +339,9 @@ describe("Settings", () => {
await fireEvent.submit(button.closest("form")!);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true);
expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(
true,
);
});
});
});
@@ -535,7 +570,9 @@ describe("Settings", () => {
);
await waitFor(() => {
const errors = getErrorToasts();
expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true);
expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(
true,
);
});
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { vi } from "vitest";
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
import { _testSetState, _testResetState } from "../lib/auth.svelte.ts";
import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts";
import { _testResetState, _testSetState } from "../lib/auth.svelte.ts";
import { clearAllToasts, getToasts, toast } from "../lib/toast.svelte.ts";
import {
unsafeAsAccessToken,
unsafeAsDid,
@@ -81,7 +81,7 @@ export function getErrorToasts(): string[] {
.map((t) => t.message);
}
export { toast, getToasts };
export { getToasts, toast };
function extractEndpoint(url: string): string {
const match = url.match(/\/xrpc\/([^?]+)/);
return match ? match[1] : url;
+15 -8
View File
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
checkForOAuthCallback,
clearOAuthCallbackParams,
generateCodeChallenge,
generateCodeVerifier,
generateState,
saveOAuthState,
checkForOAuthCallback,
clearOAuthCallbackParams,
} from "../lib/oauth";
} from "../lib/oauth.ts";
describe("OAuth utilities", () => {
beforeEach(() => {
@@ -21,7 +21,9 @@ describe("OAuth utilities", () => {
});
it("generates unique values", () => {
const states = new Set(Array.from({ length: 100 }, () => generateState()));
const states = new Set(
Array.from({ length: 100 }, () => generateState()),
);
expect(states.size).toBe(100);
});
});
@@ -67,7 +69,9 @@ describe("OAuth utilities", () => {
});
it("produces correct S256 challenge", async () => {
const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
const challenge = await generateCodeChallenge(
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
});
@@ -191,9 +195,10 @@ describe("OAuth utilities", () => {
describe("DPoP proof generation", () => {
it("base64url encoding produces valid output", async () => {
const testData = new Uint8Array([72, 101, 108, 108, 111]);
const buffer = testData.buffer;
const _buffer = testData.buffer;
const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join("");
const binary = Array.from(testData, (byte) => String.fromCharCode(byte))
.join("");
const base64url = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -220,7 +225,9 @@ describe("DPoP proof generation", () => {
y: jwk.y,
});
expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}');
expect(canonical).toBe(
'{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}',
);
const keys = Object.keys(JSON.parse(canonical));
expect(keys).toEqual(["crv", "kty", "x", "y"]);