mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-10 12:16:05 +00:00
sso signup & login
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メールを更新",
|
||||
|
||||
@@ -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": "이메일 업데이트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "更新邮箱",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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')} →
|
||||
</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}
|
||||
· {$_('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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user