mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 09:14:15 +00:00
feat: oauth prompt=create, other frontend fixes
This commit is contained in:
+12
-10
@@ -6,8 +6,6 @@
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n'
|
||||
import Toast from './components/Toast.svelte'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import RegisterPasskey from './routes/RegisterPasskey.svelte'
|
||||
import RegisterSso from './routes/RegisterSso.svelte'
|
||||
import Verify from './routes/Verify.svelte'
|
||||
import ResetPassword from './routes/ResetPassword.svelte'
|
||||
@@ -29,7 +27,9 @@
|
||||
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 SsoRegisterComplete from './routes/SsoRegisterComplete.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import RegisterPassword from './routes/RegisterPassword.svelte'
|
||||
import Security from './routes/Security.svelte'
|
||||
import TrustedDevices from './routes/TrustedDevices.svelte'
|
||||
import Controllers from './routes/Controllers.svelte'
|
||||
@@ -98,12 +98,6 @@
|
||||
switch (path) {
|
||||
case '/login':
|
||||
return Login
|
||||
case '/register':
|
||||
return RegisterPasskey
|
||||
case '/register-password':
|
||||
return Register
|
||||
case '/register-sso':
|
||||
return RegisterSso
|
||||
case '/verify':
|
||||
return Verify
|
||||
case '/reset-password':
|
||||
@@ -145,7 +139,14 @@
|
||||
case '/oauth/error':
|
||||
return OAuthError
|
||||
case '/oauth/sso-register':
|
||||
return OAuthSsoRegister
|
||||
return SsoRegisterComplete
|
||||
case '/register':
|
||||
case '/oauth/register':
|
||||
return Register
|
||||
case '/oauth/register-sso':
|
||||
return RegisterSso
|
||||
case '/oauth/register-password':
|
||||
return RegisterPassword
|
||||
case '/security':
|
||||
return Security
|
||||
case '/trusted-devices':
|
||||
@@ -167,6 +168,7 @@
|
||||
|
||||
let currentPath = $derived(getCurrentPath())
|
||||
let CurrentComponent = $derived(getComponent(currentPath))
|
||||
|
||||
</script>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -6,20 +6,30 @@
|
||||
interface Props {
|
||||
active: 'passkey' | 'password' | 'sso'
|
||||
ssoAvailable?: boolean
|
||||
oauthRequestUri?: string | null
|
||||
}
|
||||
|
||||
let { active, ssoAvailable = true }: Props = $props()
|
||||
let { active, ssoAvailable = true, oauthRequestUri = null }: Props = $props()
|
||||
|
||||
function buildOauthUrl(route: string): string {
|
||||
const url = getFullUrl(route)
|
||||
return oauthRequestUri ? `${url}?request_uri=${encodeURIComponent(oauthRequestUri)}` : url
|
||||
}
|
||||
|
||||
const passkeyUrl = $derived(buildOauthUrl(routes.oauthRegister))
|
||||
const passwordUrl = $derived(buildOauthUrl(routes.oauthRegisterPassword))
|
||||
const ssoUrl = $derived(buildOauthUrl(routes.oauthRegisterSso))
|
||||
</script>
|
||||
|
||||
<div class="account-type-switcher">
|
||||
<a href={getFullUrl(routes.register)} class="switcher-option" class:active={active === 'passkey'}>
|
||||
<a href={passkeyUrl} class="switcher-option" class:active={active === 'passkey'}>
|
||||
{$_('register.passkeyAccount')}
|
||||
</a>
|
||||
<a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}>
|
||||
<a href={passwordUrl} 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'}>
|
||||
<a href={ssoUrl} class="switcher-option" class:active={active === 'sso'}>
|
||||
{$_('register.ssoAccount')}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<div class="modal-content">
|
||||
{#if activeMethod === 'password'}
|
||||
<form onsubmit={handlePasswordSubmit}>
|
||||
<div class="form-group">
|
||||
<div class="field">
|
||||
<label for="reauth-password">{$_('reauth.password')}</label>
|
||||
<input
|
||||
id="reauth-password"
|
||||
@@ -196,13 +196,13 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !password}>
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'totp'}
|
||||
<form onsubmit={handleTotpSubmit}>
|
||||
<div class="form-group">
|
||||
<div class="field">
|
||||
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
|
||||
<input
|
||||
id="reauth-totp"
|
||||
@@ -215,18 +215,14 @@
|
||||
maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
|
||||
<button type="submit" disabled={loading || !totpCode}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'passkey'}
|
||||
<div class="passkey-auth">
|
||||
<p>{$_('reauth.passkeyPrompt')}</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={handlePasskeyAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
<button onclick={handlePasskeyAuth} disabled={loading}>
|
||||
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -234,7 +230,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
|
||||
<button class="secondary" onclick={handleClose} disabled={loading}>
|
||||
{$_('reauth.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,18 +242,18 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--overlay-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
max-width: 400px;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-width: var(--width-sm);
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
@@ -267,19 +263,19 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
font-size: var(--text-xl);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
@@ -291,36 +287,36 @@
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
padding: 1rem 1.5rem 0;
|
||||
padding: var(--space-4) var(--space-6) 0;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 1rem 1.5rem 0;
|
||||
padding: 0.75rem;
|
||||
margin: var(--space-4) var(--space-6) 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-text);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.method-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem 0;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-6) 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
@@ -334,32 +330,11 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 1.5rem;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
.modal-content .field {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.passkey-auth {
|
||||
@@ -367,52 +342,17 @@
|
||||
}
|
||||
|
||||
.passkey-auth p {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.modal-content button:not(.tab) {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,8 +70,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,7 +69,4 @@
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -310,6 +310,23 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
checkEmailInUse(email: string): Promise<{ inUse: boolean }> {
|
||||
return xrpc("_account.checkEmailInUse", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
},
|
||||
|
||||
checkCommsChannelInUse(
|
||||
channel: "email" | "discord" | "telegram" | "signal",
|
||||
identifier: string,
|
||||
): Promise<{ inUse: boolean }> {
|
||||
return xrpc("_account.checkCommsChannelInUse", {
|
||||
method: "POST",
|
||||
body: { channel, identifier },
|
||||
});
|
||||
},
|
||||
|
||||
async getSession(token: AccessToken): Promise<Session> {
|
||||
const raw = await xrpc<unknown>("com.atproto.server.getSession", { token });
|
||||
return castSession(raw);
|
||||
|
||||
@@ -915,6 +915,7 @@ export function createInboundMigrationFlow() {
|
||||
authMethod: "password",
|
||||
passkeySetupToken: null,
|
||||
oauthCodeVerifier: null,
|
||||
localAccessToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * from "./types";
|
||||
export * from "./atproto-client";
|
||||
export * from "./storage";
|
||||
export * from "./blob-migration";
|
||||
export * from "./types.ts";
|
||||
export * from "./atproto-client.ts";
|
||||
export * from "./storage.ts";
|
||||
export * from "./blob-migration.ts";
|
||||
export {
|
||||
createInboundMigrationFlow,
|
||||
type InboundMigrationFlow,
|
||||
} from "./flow.svelte";
|
||||
} from "./flow.svelte.ts";
|
||||
export {
|
||||
clearOfflineState,
|
||||
createOfflineInboundMigrationFlow,
|
||||
getOfflineResumeInfo,
|
||||
hasPendingOfflineMigration,
|
||||
} from "./offline-flow.svelte";
|
||||
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
|
||||
} from "./offline-flow.svelte.ts";
|
||||
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte.ts";
|
||||
|
||||
@@ -261,7 +261,10 @@ function extractDPoPNonceFromResponse(response: Response): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startOAuthLogin(loginHint?: string): Promise<void> {
|
||||
async function startOAuthFlow(options?: {
|
||||
loginHint?: string;
|
||||
prompt?: string;
|
||||
}): Promise<void> {
|
||||
clearAllOAuthState();
|
||||
|
||||
const state = generateState();
|
||||
@@ -283,8 +286,11 @@ export async function startOAuthLogin(loginHint?: string): Promise<void> {
|
||||
code_challenge_method: "S256",
|
||||
dpop_jkt: dpopJkt,
|
||||
};
|
||||
if (loginHint) {
|
||||
parParams.login_hint = loginHint;
|
||||
if (options?.loginHint) {
|
||||
parParams.login_hint = options.loginHint;
|
||||
}
|
||||
if (options?.prompt) {
|
||||
parParams.prompt = options.prompt;
|
||||
}
|
||||
|
||||
const parResponse = await fetch("/oauth/par", {
|
||||
@@ -311,6 +317,77 @@ export async function startOAuthLogin(loginHint?: string): Promise<void> {
|
||||
globalThis.location.href = authorizeUrl.toString();
|
||||
}
|
||||
|
||||
export async function startOAuthLogin(loginHint?: string): Promise<void> {
|
||||
return startOAuthFlow({ loginHint });
|
||||
}
|
||||
|
||||
export async function startOAuthRegister(): Promise<void> {
|
||||
return startOAuthFlow({ prompt: "create" });
|
||||
}
|
||||
|
||||
export async function getOAuthRequestUri(prompt?: string): Promise<string> {
|
||||
clearAllOAuthState();
|
||||
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
const keyPair = await getOrCreateDPoPKeyPair();
|
||||
const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
|
||||
|
||||
saveOAuthState({ state, codeVerifier });
|
||||
|
||||
const parParams: Record<string, string> = {
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: SCOPES,
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
dpop_jkt: dpopJkt,
|
||||
};
|
||||
if (prompt) {
|
||||
parParams.prompt = prompt;
|
||||
}
|
||||
|
||||
const parResponse = await fetch("/oauth/par", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(parParams),
|
||||
});
|
||||
|
||||
if (!parResponse.ok) {
|
||||
const error = await parResponse.json().catch(() => ({
|
||||
error: "Unknown error",
|
||||
}));
|
||||
throw new Error(
|
||||
error.error_description || error.error || "Failed to get request URI",
|
||||
);
|
||||
}
|
||||
|
||||
const { request_uri } = await parResponse.json();
|
||||
return request_uri;
|
||||
}
|
||||
|
||||
export function getRequestUriFromUrl(): string | null {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
return params.get("request_uri");
|
||||
}
|
||||
|
||||
export async function ensureRequestUri(
|
||||
prompt = "create",
|
||||
): Promise<string | null> {
|
||||
const existing = getRequestUriFromUrl();
|
||||
if (existing) return existing;
|
||||
|
||||
const newRequestUri = await getOAuthRequestUri(prompt);
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.set("request_uri", newRequestUri);
|
||||
globalThis.location.href = url.toString();
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
|
||||
@@ -49,73 +49,3 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-password-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { api, ApiError } from '../api'
|
||||
import { resendVerification } from '../auth.svelte'
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
@@ -13,6 +14,35 @@
|
||||
let resending = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) {
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (verificationCode.trim()) return
|
||||
const advanced = await flow.checkAndAdvanceIfVerified()
|
||||
if (advanced && pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return 'email'
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface RegistrationFlowState {
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
pdsHostname: string;
|
||||
emailInUse: boolean;
|
||||
discordInUse: boolean;
|
||||
telegramInUse: boolean;
|
||||
signalInUse: boolean;
|
||||
}
|
||||
|
||||
export function createRegistrationFlow(
|
||||
@@ -63,6 +67,10 @@ export function createRegistrationFlow(
|
||||
error: null,
|
||||
submitting: false,
|
||||
pdsHostname,
|
||||
emailInUse: false,
|
||||
discordInUse: false,
|
||||
telegramInUse: false,
|
||||
signalInUse: false,
|
||||
});
|
||||
|
||||
function getPdsEndpoint(): string {
|
||||
@@ -105,6 +113,36 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEmailInUse(email: string): Promise<void> {
|
||||
if (!email.trim() || !email.includes("@")) {
|
||||
state.emailInUse = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.checkEmailInUse(email.trim());
|
||||
state.emailInUse = result.inUse;
|
||||
} catch {
|
||||
state.emailInUse = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCommsChannelInUse(
|
||||
channel: "discord" | "telegram" | "signal",
|
||||
identifier: string,
|
||||
): Promise<void> {
|
||||
const trimmed = identifier.trim();
|
||||
if (!trimmed) {
|
||||
state[`${channel}InUse`] = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.checkCommsChannelInUse(channel, trimmed);
|
||||
state[`${channel}InUse`] = result.inUse;
|
||||
} catch {
|
||||
state[`${channel}InUse`] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function proceedFromInfo() {
|
||||
state.error = null;
|
||||
if (state.info.didType === "web-external") {
|
||||
@@ -356,6 +394,73 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
let checkingVerification = false;
|
||||
|
||||
async function checkAndAdvanceIfVerified(): Promise<boolean> {
|
||||
if (checkingVerification || !state.account) return false;
|
||||
|
||||
checkingVerification = true;
|
||||
try {
|
||||
const result = await api.checkEmailVerified(state.account.did);
|
||||
if (!result.verified) return false;
|
||||
|
||||
if (state.info.didType === "web-external") {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
};
|
||||
|
||||
if (state.externalDidWeb.keyMode === "byod") {
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
session.accessJwt,
|
||||
);
|
||||
const newPublicKeyMultibase =
|
||||
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
|
||||
"";
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account.handle,
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "updated-did-doc";
|
||||
persistState();
|
||||
} else {
|
||||
await api.activateAccount(session.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
} else {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
};
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
checkingVerification = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
switch (state.step) {
|
||||
case "key-choice":
|
||||
@@ -413,9 +518,12 @@ export function createRegistrationFlow(
|
||||
setPasskeyComplete,
|
||||
proceedFromAppPassword,
|
||||
verifyAccount,
|
||||
checkAndAdvanceIfVerified,
|
||||
activateAccount,
|
||||
finalizeSession,
|
||||
goBack,
|
||||
checkEmailInUse,
|
||||
checkCommsChannelInUse,
|
||||
|
||||
setError(msg: string) {
|
||||
state.error = msg;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./types";
|
||||
export * from "./flow.svelte";
|
||||
export * from "./types.ts";
|
||||
export * from "./flow.svelte.ts";
|
||||
export { default as VerificationStep } from "./VerificationStep.svelte";
|
||||
export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte";
|
||||
export { default as DidDocStep } from "./DidDocStep.svelte";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "./api";
|
||||
import { api } from "./api.ts";
|
||||
|
||||
interface ServerConfigState {
|
||||
serverName: string | null;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export const routes = {
|
||||
login: "/login",
|
||||
register: "/register",
|
||||
registerPassword: "/register-password",
|
||||
registerSso: "/register-sso",
|
||||
dashboard: "/dashboard",
|
||||
settings: "/settings",
|
||||
security: "/security",
|
||||
@@ -31,6 +28,9 @@ export const routes = {
|
||||
oauthDelegation: "/oauth/delegation",
|
||||
oauthError: "/oauth/error",
|
||||
oauthSsoRegister: "/oauth/sso-register",
|
||||
oauthRegister: "/oauth/register",
|
||||
oauthRegisterSso: "/oauth/register-sso",
|
||||
oauthRegisterPassword: "/oauth/register-password",
|
||||
} as const;
|
||||
|
||||
export type Route = (typeof routes)[keyof typeof routes];
|
||||
@@ -55,6 +55,9 @@ export interface RouteParams {
|
||||
[routes.oauthError]: { error?: string; error_description?: string };
|
||||
[routes.migrate]: { code?: string; state?: string };
|
||||
[routes.oauthSsoRegister]: { token?: string };
|
||||
[routes.oauthRegister]: { request_uri?: string };
|
||||
[routes.oauthRegisterSso]: { request_uri?: string };
|
||||
[routes.oauthRegisterPassword]: { request_uri?: string };
|
||||
}
|
||||
|
||||
export type RoutesWithParams = keyof RouteParams;
|
||||
|
||||
@@ -150,17 +150,21 @@
|
||||
"email": "Email",
|
||||
"emailAddress": "Email Address",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"emailInUseWarning": "This email is already associated with another account. You can still use it, but for account recovery you may need to use your handle instead.",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord User ID",
|
||||
"discordIdPlaceholder": "Your Discord user ID",
|
||||
"discordIdHint": "Your numeric Discord user ID (enable Developer Mode to find it)",
|
||||
"discordInUseWarning": "This Discord ID is already associated with another account.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram Username",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "This Telegram username is already associated with another account.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal Phone Number",
|
||||
"signalNumberPlaceholder": "+1234567890",
|
||||
"signalNumberHint": "Include country code (eg., +1 for US)",
|
||||
"signalInUseWarning": "This Signal number is already associated with another account.",
|
||||
"notConfigured": "not configured",
|
||||
"inviteCode": "Invite Code",
|
||||
"inviteCodePlaceholder": "Enter your invite code",
|
||||
@@ -275,6 +279,7 @@
|
||||
"currentEmail": "Current: {email}",
|
||||
"newEmail": "New Email",
|
||||
"newEmailPlaceholder": "new@example.com",
|
||||
"emailInUseWarning": "This email is already used by another account. You can still use it, but account recovery may require your handle.",
|
||||
"changeEmailButton": "Change Email",
|
||||
"requesting": "Requesting...",
|
||||
"verificationCode": "Verification Code",
|
||||
@@ -437,6 +442,7 @@
|
||||
"noCodes": "No invite codes yet",
|
||||
"available": "Available",
|
||||
"used": "Used by @{handle}",
|
||||
"spent": "Spent",
|
||||
"disabled": "Disabled",
|
||||
"usedBy": "Used by",
|
||||
"disableConfirm": "Disable this invite code? It can no longer be used.",
|
||||
@@ -577,7 +583,10 @@
|
||||
"hideHistory": "Hide History",
|
||||
"noMessages": "No messages found.",
|
||||
"sent": "sent",
|
||||
"failed": "failed"
|
||||
"failed": "failed",
|
||||
"discordInUseWarning": "This Discord ID is already associated with another account.",
|
||||
"telegramInUseWarning": "This Telegram username is already associated with another account.",
|
||||
"signalInUseWarning": "This Signal number is already associated with another account."
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "Repository Explorer",
|
||||
@@ -777,6 +786,12 @@
|
||||
"subtitle": "Select an account to continue",
|
||||
"useAnother": "Use a different account"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"subtitle": "Create an account to continue to",
|
||||
"subtitleGeneric": "Create an account to continue",
|
||||
"haveAccount": "Already have an account? Sign in"
|
||||
},
|
||||
"twoFactor": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"subtitle": "Additional verification is required",
|
||||
@@ -887,6 +902,7 @@
|
||||
"sendCode": "Send Reset Code",
|
||||
"sending": "Sending...",
|
||||
"codeSent": "Password reset code sent! Check your preferred notification channel.",
|
||||
"multipleAccountsWarning": "Multiple accounts share this email. The reset code was sent to the most recently created account. Use your handle instead for a specific account.",
|
||||
"enterCode": "Enter the code you received and your new password.",
|
||||
"code": "Reset Code",
|
||||
"codePlaceholder": "Enter reset code",
|
||||
|
||||
@@ -150,17 +150,21 @@
|
||||
"email": "Sähköposti",
|
||||
"emailAddress": "Sähköpostiosoite",
|
||||
"emailPlaceholder": "sinä@esimerkki.fi",
|
||||
"emailInUseWarning": "Tämä sähköposti on jo yhdistetty toiseen tiliin. Voit silti käyttää sitä, mutta tilin palauttamiseen saatat joutua käyttämään käsittelynimeäsi.",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord-käyttäjätunnus",
|
||||
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
|
||||
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
|
||||
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram-käyttäjänimi",
|
||||
"telegramUsernamePlaceholder": "@käyttäjänimesi",
|
||||
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal-puhelinnumero",
|
||||
"signalNumberPlaceholder": "+358401234567",
|
||||
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
|
||||
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
|
||||
"notConfigured": "ei määritetty",
|
||||
"inviteCode": "Kutsukoodi",
|
||||
"inviteCodePlaceholder": "Syötä kutsukoodisi",
|
||||
@@ -275,6 +279,7 @@
|
||||
"currentEmail": "Nykyinen: {email}",
|
||||
"newEmail": "Uusi sähköposti",
|
||||
"newEmailPlaceholder": "uusi@esimerkki.fi",
|
||||
"emailInUseWarning": "Tämä sähköposti on jo toisen tilin käytössä. Voit silti käyttää sitä, mutta tilin palauttaminen voi vaatia käsittelynimeäsi.",
|
||||
"changeEmailButton": "Vaihda sähköposti",
|
||||
"requesting": "Pyydetään...",
|
||||
"verificationCode": "Vahvistuskoodi",
|
||||
@@ -437,6 +442,7 @@
|
||||
"noCodes": "Ei vielä kutsukoodeja",
|
||||
"available": "Saatavilla",
|
||||
"used": "Käyttänyt @{handle}",
|
||||
"spent": "Käytetty",
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"usedBy": "Käyttänyt",
|
||||
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
|
||||
@@ -577,7 +583,10 @@
|
||||
"hideHistory": "Piilota historia",
|
||||
"noMessages": "Viestejä ei löytynyt.",
|
||||
"sent": "lähetetty",
|
||||
"failed": "epäonnistui"
|
||||
"failed": "epäonnistui",
|
||||
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
|
||||
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
|
||||
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin."
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "Tietovarastoselaaja",
|
||||
@@ -696,6 +705,12 @@
|
||||
"orContinueWith": "Tai jatka käyttäen",
|
||||
"orUseCredentials": "Tai kirjaudu tunnuksilla"
|
||||
},
|
||||
"register": {
|
||||
"title": "Luo tili",
|
||||
"subtitle": "Luo tili jatkaaksesi sovellukseen",
|
||||
"subtitleGeneric": "Luo tili jatkaaksesi",
|
||||
"haveAccount": "Onko sinulla jo tili? Kirjaudu sisään"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "Linkitetyt tilit",
|
||||
"linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.",
|
||||
@@ -829,7 +844,13 @@
|
||||
"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."
|
||||
"emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se.",
|
||||
"infoAfterTitle": "Tilin luomisen jälkeen",
|
||||
"infoAddPassword": "Lisää salasana perinteistä kirjautumista varten",
|
||||
"infoAddPasskey": "Määritä pääsyavain salasanattomaan kirjautumiseen",
|
||||
"infoLinkProviders": "Linkitä lisää SSO-palveluntarjoajia",
|
||||
"infoChangeHandle": "Vaihda käsittelynimesi tai käytä omaa verkkotunnusta",
|
||||
"tryAgain": "Yritä uudelleen"
|
||||
},
|
||||
"verify": {
|
||||
"title": "Vahvista tilisi",
|
||||
@@ -881,6 +902,7 @@
|
||||
"sendCode": "Lähetä palautuskoodi",
|
||||
"sending": "Lähetetään...",
|
||||
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
|
||||
"multipleAccountsWarning": "Useampi tili käyttää tätä sähköpostia. Palautuskoodi lähetettiin viimeksi luodulle tilille. Käytä käsittelynimeäsi tietylle tilille.",
|
||||
"enterCode": "Syötä saamasi koodi ja uusi salasanasi.",
|
||||
"code": "Palautuskoodi",
|
||||
"codePlaceholder": "Syötä palautuskoodi",
|
||||
|
||||
@@ -143,17 +143,21 @@
|
||||
"email": "メール",
|
||||
"emailAddress": "メールアドレス",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"emailInUseWarning": "このメールアドレスは既に別のアカウントに関連付けられています。引き続き使用できますが、アカウント回復にはハンドルが必要になる場合があります。",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord ユーザー ID",
|
||||
"discordIdPlaceholder": "Discord ユーザー ID",
|
||||
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
|
||||
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram ユーザー名",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 電話番号",
|
||||
"signalNumberPlaceholder": "+81XXXXXXXXXX",
|
||||
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
|
||||
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
|
||||
"notConfigured": "未設定",
|
||||
"inviteCode": "招待コード",
|
||||
"inviteCodePlaceholder": "招待コードを入力",
|
||||
@@ -268,6 +272,7 @@
|
||||
"currentEmail": "現在: {email}",
|
||||
"newEmail": "新しいメール",
|
||||
"newEmailPlaceholder": "new@example.com",
|
||||
"emailInUseWarning": "このメールアドレスは既に別のアカウントで使用されています。引き続き使用できますが、アカウント回復にはハンドルが必要になる場合があります。",
|
||||
"changeEmailButton": "メールを変更",
|
||||
"requesting": "リクエスト中...",
|
||||
"verificationCode": "確認コード",
|
||||
@@ -430,6 +435,7 @@
|
||||
"noCodes": "招待コードはまだありません",
|
||||
"available": "利用可能",
|
||||
"used": "@{handle} が使用済み",
|
||||
"spent": "使用済み",
|
||||
"disabled": "無効",
|
||||
"usedBy": "使用者",
|
||||
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
|
||||
@@ -570,7 +576,10 @@
|
||||
"hideHistory": "履歴を隠す",
|
||||
"noMessages": "メッセージが見つかりません。",
|
||||
"sent": "送信済み",
|
||||
"failed": "失敗"
|
||||
"failed": "失敗",
|
||||
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
|
||||
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
|
||||
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。"
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "リポジトリエクスプローラー",
|
||||
@@ -689,6 +698,12 @@
|
||||
"orContinueWith": "または次の方法で続行",
|
||||
"orUseCredentials": "または認証情報でサインイン"
|
||||
},
|
||||
"register": {
|
||||
"title": "アカウント作成",
|
||||
"subtitle": "続行するにはアカウントを作成してください",
|
||||
"subtitleGeneric": "続行するにはアカウントを作成してください",
|
||||
"haveAccount": "すでにアカウントをお持ちですか?サインイン"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "連携アカウント",
|
||||
"linkedAccountsDesc": "シングルサインオン用に連携された外部アカウント。",
|
||||
@@ -822,7 +837,13 @@
|
||||
"error_expired": "登録セッションが期限切れです。もう一度お試しください。",
|
||||
"error_handle_required": "ハンドルを選択してください",
|
||||
"emailVerifiedByProvider": "このメールアドレスは{provider}で確認済みです。追加の確認は不要です。",
|
||||
"emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。"
|
||||
"emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。",
|
||||
"infoAfterTitle": "アカウント作成後",
|
||||
"infoAddPassword": "従来のログイン用にパスワードを追加",
|
||||
"infoAddPasskey": "パスワードレスサインイン用にパスキーを設定",
|
||||
"infoLinkProviders": "追加のSSOプロバイダーを連携",
|
||||
"infoChangeHandle": "ハンドルの変更またはカスタムドメインの使用",
|
||||
"tryAgain": "もう一度試す"
|
||||
},
|
||||
"verify": {
|
||||
"title": "アカウント確認",
|
||||
@@ -874,6 +895,7 @@
|
||||
"sendCode": "リセットコードを送信",
|
||||
"sending": "送信中...",
|
||||
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
|
||||
"multipleAccountsWarning": "複数のアカウントがこのメールを共有しています。リセットコードは最後に作成されたアカウントに送信されました。特定のアカウントにはハンドルを使用してください。",
|
||||
"enterCode": "受け取ったコードと新しいパスワードを入力してください。",
|
||||
"code": "リセットコード",
|
||||
"codePlaceholder": "リセットコードを入力",
|
||||
|
||||
@@ -143,17 +143,21 @@
|
||||
"email": "이메일",
|
||||
"emailAddress": "이메일 주소",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"emailInUseWarning": "이 이메일은 이미 다른 계정과 연결되어 있습니다. 계속 사용할 수 있지만, 계정 복구 시 핸들이 필요할 수 있습니다.",
|
||||
"discord": "Discord",
|
||||
"discordId": "Discord 사용자 ID",
|
||||
"discordIdPlaceholder": "Discord 사용자 ID",
|
||||
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
|
||||
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram 사용자 이름",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 전화번호",
|
||||
"signalNumberPlaceholder": "+821012345678",
|
||||
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
|
||||
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"notConfigured": "구성되지 않음",
|
||||
"inviteCode": "초대 코드",
|
||||
"inviteCodePlaceholder": "초대 코드 입력",
|
||||
@@ -269,6 +273,7 @@
|
||||
"newEmail": "새 이메일",
|
||||
"newEmailPlaceholder": "new@example.com",
|
||||
"changeEmailButton": "이메일 변경",
|
||||
"emailInUseWarning": "이 이메일은 이미 다른 계정과 연결되어 있습니다. 계속 사용하실 수 있지만, 계정 복구 시 이메일 대신 핸들을 사용해야 할 수 있습니다.",
|
||||
"requesting": "요청 중...",
|
||||
"verificationCode": "인증 코드",
|
||||
"verificationCodePlaceholder": "인증 코드 입력",
|
||||
@@ -430,6 +435,7 @@
|
||||
"noCodes": "초대 코드가 아직 없습니다",
|
||||
"available": "사용 가능",
|
||||
"used": "@{handle}이(가) 사용함",
|
||||
"spent": "소진됨",
|
||||
"disabled": "비활성화됨",
|
||||
"usedBy": "사용자",
|
||||
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
|
||||
@@ -570,7 +576,10 @@
|
||||
"hideHistory": "기록 숨기기",
|
||||
"noMessages": "메시지가 없습니다.",
|
||||
"sent": "전송됨",
|
||||
"failed": "실패"
|
||||
"failed": "실패",
|
||||
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
|
||||
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
|
||||
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다."
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "저장소 탐색기",
|
||||
@@ -689,6 +698,12 @@
|
||||
"orContinueWith": "또는 다음으로 계속",
|
||||
"orUseCredentials": "또는 자격 증명으로 로그인"
|
||||
},
|
||||
"register": {
|
||||
"title": "계정 만들기",
|
||||
"subtitle": "계속하려면 계정을 만드세요",
|
||||
"subtitleGeneric": "계속하려면 계정을 만드세요",
|
||||
"haveAccount": "이미 계정이 있으신가요? 로그인"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "연결된 계정",
|
||||
"linkedAccountsDesc": "싱글 사인온을 위해 연결된 외부 계정입니다.",
|
||||
@@ -822,7 +837,13 @@
|
||||
"error_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.",
|
||||
"error_handle_required": "핸들을 선택해 주세요",
|
||||
"emailVerifiedByProvider": "이 이메일은 {provider}에서 인증되었습니다. 추가 인증이 필요하지 않습니다.",
|
||||
"emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다."
|
||||
"emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다.",
|
||||
"infoAfterTitle": "계정 생성 후",
|
||||
"infoAddPassword": "기존 로그인을 위한 비밀번호 추가",
|
||||
"infoAddPasskey": "비밀번호 없는 로그인을 위한 패스키 설정",
|
||||
"infoLinkProviders": "추가 SSO 제공자 연결",
|
||||
"infoChangeHandle": "핸들 변경 또는 사용자 정의 도메인 사용",
|
||||
"tryAgain": "다시 시도"
|
||||
},
|
||||
"verify": {
|
||||
"title": "계정 인증",
|
||||
@@ -886,7 +907,8 @@
|
||||
"success": "비밀번호가 재설정되었습니다!",
|
||||
"requestNewCode": "새 코드 요청",
|
||||
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
|
||||
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
|
||||
"multipleAccountsWarning": "여러 계정에서 이 이메일을 공유하고 있습니다. 재설정 코드는 가장 최근에 생성된 계정으로 전송되었습니다. 특정 계정을 복구하려면 핸들을 사용하세요."
|
||||
},
|
||||
"recoverPasskey": {
|
||||
"title": "계정 복구",
|
||||
|
||||
@@ -147,13 +147,16 @@
|
||||
"discordId": "Discord användar-ID",
|
||||
"discordIdPlaceholder": "Ditt Discord användar-ID",
|
||||
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
|
||||
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram-användarnamn",
|
||||
"telegramUsernamePlaceholder": "@dittanvändarnamn",
|
||||
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal-telefonnummer",
|
||||
"signalNumberPlaceholder": "+46701234567",
|
||||
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
|
||||
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
|
||||
"notConfigured": "ej konfigurerad",
|
||||
"inviteCode": "Inbjudningskod",
|
||||
"inviteCodePlaceholder": "Ange din inbjudningskod",
|
||||
@@ -161,6 +164,7 @@
|
||||
"createButton": "Skapa konto",
|
||||
"alreadyHaveAccount": "Har du redan ett konto?",
|
||||
"signIn": "Logga in",
|
||||
"emailInUseWarning": "Denna e-post är redan kopplad till ett annat konto. Du kan fortfarande använda den, men för kontoåterställning kan du behöva använda ditt användarnamn istället.",
|
||||
"passkeyAccount": "Nyckel",
|
||||
"passwordAccount": "Lösenord",
|
||||
"ssoAccount": "SSO",
|
||||
@@ -269,6 +273,7 @@
|
||||
"newEmail": "Ny e-post",
|
||||
"newEmailPlaceholder": "ny@exempel.se",
|
||||
"changeEmailButton": "Ändra e-post",
|
||||
"emailInUseWarning": "Denna e-post används redan av ett annat konto. Du kan fortfarande använda den, men kontoåterställning kan kräva ditt användarnamn.",
|
||||
"requesting": "Begär...",
|
||||
"verificationCode": "Verifieringskod",
|
||||
"verificationCodePlaceholder": "Ange verifieringskod",
|
||||
@@ -435,7 +440,8 @@
|
||||
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
|
||||
"created": "Inbjudningskod skapad",
|
||||
"copy": "Kopiera",
|
||||
"createdOn": "Skapad {date}"
|
||||
"createdOn": "Skapad {date}",
|
||||
"spent": "Förbrukad"
|
||||
},
|
||||
"security": {
|
||||
"title": "Säkerhet",
|
||||
@@ -570,7 +576,10 @@
|
||||
"hideHistory": "Dölj historik",
|
||||
"noMessages": "Inga meddelanden hittades.",
|
||||
"sent": "skickad",
|
||||
"failed": "misslyckades"
|
||||
"failed": "misslyckades",
|
||||
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
|
||||
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
|
||||
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto."
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "Dataförvarsutforskare",
|
||||
@@ -689,6 +698,12 @@
|
||||
"orContinueWith": "Eller fortsätt med",
|
||||
"orUseCredentials": "Eller logga in med uppgifter"
|
||||
},
|
||||
"register": {
|
||||
"title": "Skapa konto",
|
||||
"subtitle": "Skapa ett konto med {app}",
|
||||
"subtitleGeneric": "Skapa ett konto för att fortsätta",
|
||||
"haveAccount": "Har du redan ett konto?"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "Länkade konton",
|
||||
"linkedAccountsDesc": "Externa konton länkade till din identitet för enkel inloggning.",
|
||||
@@ -822,7 +837,13 @@
|
||||
"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."
|
||||
"emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den.",
|
||||
"infoAfterTitle": "Efter att du skapat ditt konto",
|
||||
"infoAddPassword": "Lägg till ett lösenord för traditionell inloggning",
|
||||
"infoAddPasskey": "Konfigurera en nyckel för lösenordsfri inloggning",
|
||||
"infoLinkProviders": "Länka ytterligare SSO-leverantörer",
|
||||
"infoChangeHandle": "Byt användarnamn eller använd en egen domän",
|
||||
"tryAgain": "Försök igen"
|
||||
},
|
||||
"verify": {
|
||||
"title": "Verifiera ditt konto",
|
||||
@@ -886,7 +907,8 @@
|
||||
"success": "Lösenord återställt!",
|
||||
"requestNewCode": "Begär ny kod",
|
||||
"passwordsMismatch": "Lösenorden matchar inte",
|
||||
"passwordLength": "Lösenordet måste vara minst 8 tecken"
|
||||
"passwordLength": "Lösenordet måste vara minst 8 tecken",
|
||||
"multipleAccountsWarning": "Flera konton delar denna e-post. Återställningskoden skickades till det senast skapade kontot. Använd ditt användarnamn istället för ett specifikt konto."
|
||||
},
|
||||
"recoverPasskey": {
|
||||
"title": "Återställ ditt konto",
|
||||
|
||||
@@ -147,13 +147,16 @@
|
||||
"discordId": "Discord 用户 ID",
|
||||
"discordIdPlaceholder": "您的 Discord 用户 ID",
|
||||
"discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)",
|
||||
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
|
||||
"telegram": "Telegram",
|
||||
"telegramUsername": "Telegram 用户名",
|
||||
"telegramUsernamePlaceholder": "@yourusername",
|
||||
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
|
||||
"signal": "Signal",
|
||||
"signalNumber": "Signal 电话号码",
|
||||
"signalNumberPlaceholder": "+1234567890",
|
||||
"signalNumberHint": "包含国家代码(例如中国为 +86)",
|
||||
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
|
||||
"notConfigured": "未配置",
|
||||
"inviteCode": "邀请码",
|
||||
"inviteCodePlaceholder": "输入您的邀请码",
|
||||
@@ -161,6 +164,7 @@
|
||||
"createButton": "创建账户",
|
||||
"alreadyHaveAccount": "已有账户?",
|
||||
"signIn": "立即登录",
|
||||
"emailInUseWarning": "此邮箱已与其他账户关联。您仍可使用,但账户恢复时可能需要使用用户名。",
|
||||
"passkeyAccount": "通行密钥",
|
||||
"passwordAccount": "密码",
|
||||
"ssoAccount": "SSO",
|
||||
@@ -269,6 +273,7 @@
|
||||
"newEmail": "新邮箱",
|
||||
"newEmailPlaceholder": "new@example.com",
|
||||
"changeEmailButton": "更改邮箱",
|
||||
"emailInUseWarning": "此邮箱已被其他账户使用。您仍可使用,但账户恢复可能需要使用用户名。",
|
||||
"requesting": "请求中...",
|
||||
"verificationCode": "验证码",
|
||||
"verificationCodePlaceholder": "输入验证码",
|
||||
@@ -435,7 +440,8 @@
|
||||
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
|
||||
"created": "邀请码已创建",
|
||||
"copy": "复制",
|
||||
"createdOn": "创建于 {date}"
|
||||
"createdOn": "创建于 {date}",
|
||||
"spent": "已使用"
|
||||
},
|
||||
"security": {
|
||||
"title": "安全设置",
|
||||
@@ -570,7 +576,10 @@
|
||||
"hideHistory": "隐藏历史",
|
||||
"noMessages": "暂无消息记录",
|
||||
"sent": "已发送",
|
||||
"failed": "发送失败"
|
||||
"failed": "发送失败",
|
||||
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
|
||||
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
|
||||
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。"
|
||||
},
|
||||
"repoExplorer": {
|
||||
"title": "数据浏览器",
|
||||
@@ -689,6 +698,12 @@
|
||||
"orContinueWith": "或使用以下方式继续",
|
||||
"orUseCredentials": "或使用凭证登录"
|
||||
},
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
"subtitle": "使用 {app} 创建账户",
|
||||
"subtitleGeneric": "创建账户以继续",
|
||||
"haveAccount": "已有账户?"
|
||||
},
|
||||
"sso": {
|
||||
"linkedAccounts": "已关联账户",
|
||||
"linkedAccountsDesc": "已关联到您身份的外部账户,用于单点登录。",
|
||||
@@ -822,7 +837,13 @@
|
||||
"error_expired": "注册会话已过期。请重试。",
|
||||
"error_handle_required": "请选择一个昵称",
|
||||
"emailVerifiedByProvider": "此邮箱已由{provider}验证。无需额外验证。",
|
||||
"emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。"
|
||||
"emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。",
|
||||
"infoAfterTitle": "创建账户后",
|
||||
"infoAddPassword": "添加密码以使用传统方式登录",
|
||||
"infoAddPasskey": "设置通行密钥以实现无密码登录",
|
||||
"infoLinkProviders": "关联其他SSO提供商",
|
||||
"infoChangeHandle": "更改用户名或使用自定义域名",
|
||||
"tryAgain": "重试"
|
||||
},
|
||||
"verify": {
|
||||
"title": "验证账户",
|
||||
@@ -886,7 +907,8 @@
|
||||
"success": "密码重置成功!",
|
||||
"requestNewCode": "重新获取验证码",
|
||||
"passwordsMismatch": "两次输入的密码不一致",
|
||||
"passwordLength": "密码至少需要8位字符"
|
||||
"passwordLength": "密码至少需要8位字符",
|
||||
"multipleAccountsWarning": "多个账户共享此邮箱。重置验证码已发送至最新创建的账户。如需恢复特定账户,请使用用户名。"
|
||||
},
|
||||
"recoverPasskey": {
|
||||
"title": "恢复账户",
|
||||
|
||||
@@ -1010,7 +1010,7 @@
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@@ -1032,7 +1032,7 @@
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #900;
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.muted {
|
||||
@@ -1049,15 +1049,12 @@
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
inset: 0;
|
||||
background: var(--overlay-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -1117,7 +1114,7 @@
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
.password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
@@ -469,8 +469,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
let verifyingChannel = $state<string | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let historyLoading = $state(true)
|
||||
let discordInUse = $state(false)
|
||||
let telegramInUse = $state(false)
|
||||
let signalInUse = $state(false)
|
||||
let messages = $state<Array<{
|
||||
createdAt: string
|
||||
channel: string
|
||||
@@ -132,6 +135,31 @@
|
||||
function formatDate(dateStr: string): string {
|
||||
return formatDateTime(dateStr)
|
||||
}
|
||||
async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) {
|
||||
const trimmed = identifier.trim()
|
||||
if (!trimmed) {
|
||||
switch (channel) {
|
||||
case 'discord': discordInUse = false; break
|
||||
case 'telegram': telegramInUse = false; break
|
||||
case 'signal': signalInUse = false; break
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api.checkCommsChannelInUse(channel, trimmed)
|
||||
switch (channel) {
|
||||
case 'discord': discordInUse = result.inUse; break
|
||||
case 'telegram': telegramInUse = result.inUse; break
|
||||
case 'signal': signalInUse = result.inUse; break
|
||||
}
|
||||
} catch {
|
||||
switch (channel) {
|
||||
case 'discord': discordInUse = false; break
|
||||
case 'telegram': telegramInUse = false; break
|
||||
case 'signal': signalInUse = false; break
|
||||
}
|
||||
}
|
||||
}
|
||||
const channels = ['email', 'discord', 'telegram', 'signal']
|
||||
function getChannelName(id: string): string {
|
||||
switch (id) {
|
||||
@@ -242,6 +270,7 @@
|
||||
id="discord"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
onblur={() => checkChannelInUse('discord', discordId)}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('discord')}
|
||||
/>
|
||||
@@ -250,6 +279,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.discordIdHint')}</p>
|
||||
{#if discordInUse}
|
||||
<p class="config-hint warning">{$_('comms.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
@@ -277,6 +309,7 @@
|
||||
id="telegram"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
onblur={() => checkChannelInUse('telegram', telegramUsername)}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('telegram')}
|
||||
/>
|
||||
@@ -285,6 +318,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.telegramHint')}</p>
|
||||
{#if telegramInUse}
|
||||
<p class="config-hint warning">{$_('comms.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'telegram'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
@@ -312,6 +348,7 @@
|
||||
id="signal"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
onblur={() => checkChannelInUse('signal', signalNumber)}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('signal')}
|
||||
/>
|
||||
@@ -320,6 +357,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.signalHint')}</p>
|
||||
{#if signalInUse}
|
||||
<p class="config-hint warning">{$_('comms.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'signal'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
@@ -573,6 +613,10 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.config-hint.warning {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -677,11 +721,6 @@
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
@@ -772,8 +811,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -770,8 +770,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -523,11 +523,6 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.deactivated-banner {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
|
||||
@@ -333,8 +333,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
}
|
||||
|
||||
.handle-item span {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -475,8 +475,4 @@
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
<span class="status disabled">{$_('inviteCodes.disabled')}</span>
|
||||
{:else if code.uses.length > 0}
|
||||
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span>
|
||||
{:else if code.available === 0}
|
||||
<span class="status spent">{$_('inviteCodes.spent')}</span>
|
||||
{:else}
|
||||
<span class="status available">{$_('inviteCodes.available')}</span>
|
||||
{/if}
|
||||
@@ -217,7 +219,7 @@
|
||||
|
||||
.code-display code {
|
||||
font-size: var(--text-lg);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -273,7 +275,7 @@
|
||||
}
|
||||
|
||||
.code-main code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -317,6 +319,11 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status.spent {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.status.disabled {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
@@ -334,8 +341,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
.account-did {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
|
||||
@@ -208,8 +208,8 @@
|
||||
{/if}
|
||||
|
||||
{#if oauthLoading}
|
||||
<div class="oauth-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading">
|
||||
<div class="spinner md"></div>
|
||||
<p>{$_('migration.oauthCompleting')}</p>
|
||||
</div>
|
||||
{:else if oauthError}
|
||||
@@ -317,7 +317,7 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: left;
|
||||
@@ -411,18 +411,18 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--overlay-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
max-width: 400px;
|
||||
max-width: var(--width-sm);
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@
|
||||
}
|
||||
|
||||
.detail-row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row .label {
|
||||
@@ -472,34 +472,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.oauth-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.oauth-loading p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.oauth-error {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -487,7 +487,7 @@
|
||||
}
|
||||
|
||||
.account-info .did {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../lib/i18n'
|
||||
import { routes, buildUrl } from '../lib/types/routes'
|
||||
import { getRequestUriFromUrl } from '../lib/oauth'
|
||||
|
||||
function getError(): string {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -15,11 +17,19 @@
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
function handleSignIn() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
const url = requestUri
|
||||
? buildUrl(routes.oauthLogin, { request_uri: requestUri })
|
||||
: routes.login
|
||||
window.location.href = `/app${url}`
|
||||
}
|
||||
|
||||
let error = $derived(getError())
|
||||
let errorDescription = $derived(getErrorDescription())
|
||||
</script>
|
||||
|
||||
<div class="oauth-error-container">
|
||||
<div class="page-sm text-center">
|
||||
<h1>{$_('oauth.error.title')}</h1>
|
||||
|
||||
<div class="error-box">
|
||||
@@ -29,19 +39,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="button" onclick={handleBack}>
|
||||
{$_('oauth.error.tryAgain')}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button type="button" onclick={handleBack}>
|
||||
{$_('oauth.error.tryAgain')}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={handleSignIn}>
|
||||
{$_('common.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.oauth-error-container {
|
||||
max-width: var(--width-sm);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-6) 0;
|
||||
color: var(--error-text);
|
||||
@@ -56,7 +64,7 @@
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
color: var(--error-text);
|
||||
margin-bottom: var(--space-2);
|
||||
@@ -67,17 +75,9 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-hover);
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
const response = await fetch('/oauth/sso/providers')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
ssoProviders = data.providers || []
|
||||
ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
|
||||
}
|
||||
} catch {
|
||||
ssoProviders = []
|
||||
@@ -337,35 +337,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/deny', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ request_uri: requestUri })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
}
|
||||
} catch {
|
||||
window.history.back()
|
||||
}
|
||||
function handleCancel() {
|
||||
window.location.href = '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="oauth-login-container">
|
||||
<div class="page-sm">
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauth.login.title')}</h1>
|
||||
<p class="subtitle">
|
||||
@@ -378,7 +355,7 @@
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
@@ -406,7 +383,7 @@
|
||||
disabled={submitting || ssoLoading !== null}
|
||||
>
|
||||
{#if ssoLoading === provider.provider}
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="spinner sm"></span>
|
||||
{:else}
|
||||
<SsoIcon provider={provider.icon} size={20} />
|
||||
{/if}
|
||||
@@ -543,25 +520,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.oauth-login-container {
|
||||
max-width: var(--width-md);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -582,6 +540,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.passkey-method,
|
||||
.password-method {
|
||||
display: flex;
|
||||
@@ -652,32 +622,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remember-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -692,15 +636,6 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--space-3);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
@@ -709,17 +644,6 @@
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-row {
|
||||
@@ -757,7 +681,6 @@
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
|
||||
.passkey-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -867,31 +790,4 @@
|
||||
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>
|
||||
|
||||
@@ -197,12 +197,6 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
|
||||
+464
-456
File diff suppressed because it is too large
Load Diff
@@ -1,668 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createRegistrationFlow,
|
||||
restoreRegistrationFlow,
|
||||
VerificationStep,
|
||||
KeyChoiceStep,
|
||||
DidDocStep,
|
||||
AppPasswordStep,
|
||||
} from '../lib/registration'
|
||||
import {
|
||||
prepareCreationOptions,
|
||||
serializeAttestationResponse,
|
||||
type WebAuthnCreationOptionsResponse,
|
||||
} from '../lib/webauthn'
|
||||
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
|
||||
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
availableCommsChannels?: string[]
|
||||
selfHostedDidWebEnabled?: boolean
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
let ssoAvailable = $state(false)
|
||||
|
||||
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
|
||||
let passkeyName = $state('')
|
||||
|
||||
$effect(() => {
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
let creatingStarted = false
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'creating' && !creatingStarted) {
|
||||
creatingStarted = true
|
||||
flow.createPasskeyAccount()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const restored = restoreRegistrationFlow()
|
||||
if (restored && restored.state.mode === 'passkey') {
|
||||
flow = restored
|
||||
serverInfo = await api.describeServer()
|
||||
} else {
|
||||
serverInfo = await api.describeServer()
|
||||
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
flow = createRegistrationFlow('passkey', hostname)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
loadingServerInfo = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateInfoStep(): string | null {
|
||||
if (!flow) return 'Flow not initialized'
|
||||
const info = flow.info
|
||||
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
|
||||
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
|
||||
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
|
||||
return $_('registerPasskey.errors.inviteRequired')
|
||||
}
|
||||
if (info.didType === 'web-external') {
|
||||
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
|
||||
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
|
||||
}
|
||||
switch (info.verificationChannel) {
|
||||
case 'email':
|
||||
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleInfoSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!flow) return
|
||||
|
||||
const validationError = validateInfoStep()
|
||||
if (validationError) {
|
||||
flow.setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
|
||||
return
|
||||
}
|
||||
|
||||
flow.clearError()
|
||||
flow.proceedFromInfo()
|
||||
}
|
||||
|
||||
async function handleCreateAccount() {
|
||||
if (!flow) return
|
||||
await flow.createPasskeyAccount()
|
||||
}
|
||||
|
||||
async function handlePasskeyRegistration() {
|
||||
if (!flow || !flow.account) return
|
||||
|
||||
flow.setSubmitting(true)
|
||||
flow.clearError()
|
||||
|
||||
try {
|
||||
const { options } = await api.startPasskeyRegistrationForSetup(
|
||||
flow.account.did,
|
||||
flow.account.setupToken!,
|
||||
passkeyName || undefined
|
||||
)
|
||||
|
||||
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
|
||||
flow.setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
|
||||
|
||||
const result = await api.completePasskeySetup(
|
||||
flow.account.did,
|
||||
flow.account.setupToken!,
|
||||
credentialResponse,
|
||||
passkeyName || undefined
|
||||
)
|
||||
|
||||
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
|
||||
} else if (err instanceof ApiError) {
|
||||
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
|
||||
} else if (err instanceof Error) {
|
||||
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
|
||||
} else {
|
||||
flow.setError($_('registerPasskey.errors.passkeyFailed'))
|
||||
}
|
||||
} finally {
|
||||
flow.setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (flow) {
|
||||
await flow.finalizeSession()
|
||||
}
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return $_('register.email')
|
||||
case 'discord': return $_('register.discord')
|
||||
case 'telegram': return $_('register.telegram')
|
||||
case 'signal': return $_('register.signal')
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!flow?.info.handle.trim()) return ''
|
||||
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
if (domain) return `${flow.info.handle.trim()}.${domain}`
|
||||
return flow.info.handle.trim()
|
||||
})
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (!flow) return ''
|
||||
switch (flow.state.step) {
|
||||
case 'info': return $_('registerPasskey.subtitle')
|
||||
case 'key-choice': return $_('registerPasskey.subtitleKeyChoice')
|
||||
case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc')
|
||||
case 'creating': return $_('registerPasskey.subtitleCreating')
|
||||
case 'passkey': return $_('registerPasskey.subtitlePasskey')
|
||||
case 'app-password': return $_('registerPasskey.subtitleAppPassword')
|
||||
case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
|
||||
case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc')
|
||||
case 'activating': return $_('registerPasskey.subtitleActivating')
|
||||
case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete')
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
<header class="page-header">
|
||||
<h1>{$_('registerPasskey.title')}</h1>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
</header>
|
||||
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<div class="loading"></div>
|
||||
|
||||
{:else if flow.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href={getFullUrl(routes.migrate)} class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountTypeSwitcher active="passkey" {ssoAvailable} />
|
||||
|
||||
<div class="split-layout sidebar-right">
|
||||
<div class="form-section">
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">{$_('registerPasskey.handle')}</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={flow.info.handle}
|
||||
placeholder={$_('registerPasskey.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('registerPasskey.contactMethod')}</legend>
|
||||
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
|
||||
<div class="field">
|
||||
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.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 flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">{$_('registerPasskey.email')}</label>
|
||||
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('registerPasskey.identityType')}</legend>
|
||||
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWeb')}</strong>
|
||||
{#if serverInfo?.selfHostedDidWebEnabled === false}
|
||||
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
|
||||
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
|
||||
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
|
||||
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-links">
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="info-panel">
|
||||
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
|
||||
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
|
||||
|
||||
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
|
||||
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
|
||||
|
||||
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
|
||||
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'initial-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="initial"
|
||||
onConfirm={handleCreateAccount}
|
||||
onBack={() => flow?.goBack()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
<p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
|
||||
|
||||
{:else if flow.state.step === 'passkey'}
|
||||
<div class="step-content">
|
||||
<div class="field">
|
||||
<label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label>
|
||||
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} />
|
||||
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>{$_('registerPasskey.passkeyPrompt')}</p>
|
||||
<ul>
|
||||
<li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
|
||||
<li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
|
||||
<li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
|
||||
{flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
|
||||
{$_('registerPasskey.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'app-password'}
|
||||
<AppPasswordStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'updated-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="updated"
|
||||
onConfirm={() => flow?.activateAccount()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<p class="loading">{$_('registerPasskey.redirecting')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-page {
|
||||
max-width: var(--width-lg);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-links {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.link-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-text a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-7) 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
form, .step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-normal);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
margin-top: var(--space-1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.radio-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.radio-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radio-label.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio-hint.disabled-hint {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin: var(--space-4) 0 0 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.warning-box li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
padding: var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,560 @@
|
||||
<script lang="ts">
|
||||
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createRegistrationFlow,
|
||||
restoreRegistrationFlow,
|
||||
VerificationStep,
|
||||
KeyChoiceStep,
|
||||
DidDocStep,
|
||||
} from '../lib/registration'
|
||||
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
|
||||
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
|
||||
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
availableCommsChannels?: string[]
|
||||
selfHostedDidWebEnabled?: boolean
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
let ssoAvailable = $state(false)
|
||||
|
||||
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
|
||||
let confirmPassword = $state('')
|
||||
let clientName = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
ensureRequestUri().then((requestUri) => {
|
||||
if (!requestUri) return
|
||||
loadServerInfo()
|
||||
checkSsoAvailable()
|
||||
fetchClientName()
|
||||
}).catch((err) => {
|
||||
console.error('Failed to ensure OAuth request URI:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchClientName() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
if (!requestUri) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
clientName = data.client_name || null
|
||||
}
|
||||
} catch {
|
||||
clientName = null
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
completeOAuthRegistration()
|
||||
}
|
||||
})
|
||||
|
||||
let creatingStarted = false
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'creating' && !creatingStarted) {
|
||||
creatingStarted = true
|
||||
flow.createPasswordAccount()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const restored = restoreRegistrationFlow()
|
||||
if (restored && restored.state.mode === 'password') {
|
||||
flow = restored
|
||||
serverInfo = await api.describeServer()
|
||||
} else {
|
||||
serverInfo = await api.describeServer()
|
||||
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
flow = createRegistrationFlow('password', hostname)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
loadingServerInfo = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateInfoStep(): string | null {
|
||||
if (!flow) return 'Flow not initialized'
|
||||
const info = flow.info
|
||||
if (!info.handle.trim()) return $_('register.validation.handleRequired')
|
||||
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
|
||||
if (!info.password) return $_('register.validation.passwordRequired')
|
||||
if (info.password.length < 8) return $_('register.validation.passwordLength')
|
||||
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
|
||||
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
|
||||
return $_('register.validation.inviteCodeRequired')
|
||||
}
|
||||
if (info.didType === 'web-external') {
|
||||
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
|
||||
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
|
||||
}
|
||||
switch (info.verificationChannel) {
|
||||
case 'email':
|
||||
if (!info.email.trim()) return $_('register.validation.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleInfoSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!flow) return
|
||||
|
||||
const validationError = validateInfoStep()
|
||||
if (validationError) {
|
||||
flow.setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
flow.clearError()
|
||||
flow.proceedFromInfo()
|
||||
}
|
||||
|
||||
async function handleCreateAccount() {
|
||||
if (!flow) return
|
||||
await flow.createPasswordAccount()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (flow) {
|
||||
await flow.finalizeSession()
|
||||
}
|
||||
navigate(routes.dashboard)
|
||||
}
|
||||
|
||||
async function completeOAuthRegistration() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
if (!requestUri || !flow?.account) {
|
||||
navigate(routes.dashboard)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/register/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
did: flow.account.did,
|
||||
app_password: flow.account.appPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
flow.setError(data.error_description || data.error || $_('common.error'))
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
navigate(routes.dashboard)
|
||||
} catch (err) {
|
||||
console.error('OAuth registration completion failed:', err)
|
||||
flow.setError(err instanceof Error ? err.message : $_('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return $_('register.email')
|
||||
case 'discord': return $_('register.discord')
|
||||
case 'telegram': return $_('register.telegram')
|
||||
case 'signal': return $_('register.signal')
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!flow?.info.handle.trim()) return ''
|
||||
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
if (domain) return `${flow.info.handle.trim()}.${domain}`
|
||||
return flow.info.handle.trim()
|
||||
})
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (!flow) return ''
|
||||
switch (flow.state.step) {
|
||||
case 'info': return $_('register.subtitle')
|
||||
case 'key-choice': return $_('register.subtitleKeyChoice')
|
||||
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
|
||||
case 'creating': return $_('common.creating')
|
||||
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
|
||||
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
|
||||
case 'activating': return $_('register.subtitleActivating')
|
||||
case 'redirect-to-dashboard': return $_('register.subtitleComplete')
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>{$_('register.title')}</h1>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
{#if clientName}
|
||||
<p class="client-name">{$_('oauth.login.subtitle')} <strong>{clientName}</strong></p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<div class="loading">
|
||||
<div class="spinner md"></div>
|
||||
</div>
|
||||
{:else if flow.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href={getFullUrl(routes.migrate)} class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
|
||||
|
||||
<div class="split-layout sidebar-right">
|
||||
<div class="form-section">
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">{$_('register.handle')}</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={flow.info.handle}
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="password">{$_('register.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={flow.info.password}
|
||||
placeholder={$_('register.passwordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirm-password">{$_('register.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder={$_('register.confirmPasswordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('register.identityType')}</legend>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
|
||||
<span class="radio-hint">{$_('register.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWeb')}</strong>
|
||||
{#if serverInfo?.selfHostedDidWebEnabled === false}
|
||||
<span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('register.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('register.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
|
||||
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('register.externalDid')}</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={flow.info.externalDid}
|
||||
placeholder={$_('register.externalDidPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.externalDidHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="section-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={flow.info.verificationChannel} disabled={flow.state.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 flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={flow.info.email}
|
||||
onblur={() => flow?.checkEmailInUse(flow.info.email)}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.state.emailInUse}
|
||||
<p class="hint warning">{$_('register.emailInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
{#if flow.state.discordInUse}
|
||||
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={flow.info.telegramUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.state.telegramInUse}
|
||||
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
{#if flow.state.signalInUse}
|
||||
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
</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={flow.info.inviteCode}
|
||||
placeholder={$_('register.inviteCodePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-links">
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="info-panel">
|
||||
<h3>{$_('register.identityHint')}</h3>
|
||||
<p>{$_('register.infoIdentityDesc')}</p>
|
||||
|
||||
<h3>{$_('register.contactMethodHint')}</h3>
|
||||
<p>{$_('register.infoContactDesc')}</p>
|
||||
|
||||
<h3>{$_('register.infoNextTitle')}</h3>
|
||||
<p>{$_('register.infoNextDesc')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'initial-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="initial"
|
||||
onConfirm={handleCreateAccount}
|
||||
onBack={() => flow?.goBack()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
<div class="loading">
|
||||
<div class="spinner md"></div>
|
||||
<p>{$_('common.creating')}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'updated-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="updated"
|
||||
onConfirm={() => flow?.activateAccount()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<div class="loading">
|
||||
<div class="spinner md"></div>
|
||||
<p>{$_('register.redirecting')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.client-name {
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,11 @@
|
||||
<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'
|
||||
import { ensureRequestUri, getRequestUriFromUrl, getOAuthRequestUri } from '../lib/oauth'
|
||||
|
||||
interface SsoProvider {
|
||||
provider: string
|
||||
@@ -16,9 +16,19 @@
|
||||
let providers = $state<SsoProvider[]>([])
|
||||
let loading = $state(true)
|
||||
let initiating = $state<string | null>(null)
|
||||
let initialized = false
|
||||
|
||||
onMount(() => {
|
||||
fetchProviders()
|
||||
$effect(() => {
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
ensureRequestUri().then((requestUri) => {
|
||||
if (!requestUri) return
|
||||
fetchProviders()
|
||||
}).catch((err) => {
|
||||
console.error('Failed to ensure OAuth request URI:', err)
|
||||
toast.error($_('common.error'))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchProviders() {
|
||||
@@ -26,10 +36,11 @@
|
||||
const response = await fetch('/oauth/sso/providers')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
providers = data.providers || []
|
||||
providers = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
|
||||
}
|
||||
} catch {
|
||||
toast.error($_('common.error'))
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch SSO providers:', err)
|
||||
toast.error(err instanceof Error ? err.message : $_('common.error'))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
@@ -37,9 +48,10 @@
|
||||
|
||||
async function initiateRegistration(provider: string) {
|
||||
initiating = provider
|
||||
let requestUri = getRequestUriFromUrl()
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/sso/initiate', {
|
||||
let response = await fetch('/oauth/sso/initiate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -48,14 +60,24 @@
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
action: 'register',
|
||||
request_uri: requestUri,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
let data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(data.error_description || data.error || $_('common.error'))
|
||||
initiating = null
|
||||
console.log('SSO initiate failed, restarting OAuth flow', data)
|
||||
try {
|
||||
const newRequestUri = await getOAuthRequestUri('create')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('request_uri', newRequestUri)
|
||||
window.location.href = url.toString()
|
||||
} catch (e) {
|
||||
console.error('Failed to restart OAuth flow:', e)
|
||||
toast.error(data.message || data.error || $_('common.error'))
|
||||
initiating = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,14 +88,15 @@
|
||||
|
||||
toast.error($_('common.error'))
|
||||
initiating = null
|
||||
} catch {
|
||||
toast.error($_('common.error'))
|
||||
} catch (err) {
|
||||
console.error('SSO registration initiation failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : $_('common.error'))
|
||||
initiating = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-sso-page">
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>{$_('register.title')}</h1>
|
||||
<p class="subtitle">{$_('register.ssoSubtitle')}</p>
|
||||
@@ -90,11 +113,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} />
|
||||
<AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} oauthRequestUri={getRequestUriFromUrl()} />
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="spinner md"></div>
|
||||
</div>
|
||||
{:else if providers.length === 0}
|
||||
<div class="no-providers">
|
||||
@@ -132,90 +155,6 @@
|
||||
</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);
|
||||
@@ -274,20 +213,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
.provider-button .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>
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
|
||||
.did {
|
||||
margin: var(--space-1) 0 0 0;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
@@ -583,7 +583,7 @@
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -788,13 +788,13 @@
|
||||
}
|
||||
|
||||
.rkey {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cid {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -804,7 +804,7 @@
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
@@ -855,11 +855,6 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.record-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -889,7 +884,7 @@
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -944,7 +939,7 @@
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
@@ -1001,8 +996,4 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
const response = await fetch('/oauth/sso/providers')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
ssoProviders = data.providers || []
|
||||
ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
|
||||
}
|
||||
} catch {
|
||||
ssoProviders = []
|
||||
@@ -212,7 +212,7 @@
|
||||
pendingAction = () => handleUnlinkAccount(id)
|
||||
showReauthModal = true
|
||||
} else {
|
||||
toast.error(data.error_description || data.error || 'Failed to unlink account')
|
||||
toast.error(data.error_description || data.message || data.error || 'Failed to unlink account')
|
||||
}
|
||||
unlinkingId = null
|
||||
return
|
||||
@@ -1167,7 +1167,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.passkey-list {
|
||||
@@ -1411,11 +1411,6 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.skeleton-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1523,12 +1518,6 @@
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -201,11 +201,6 @@
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -57,6 +57,20 @@
|
||||
let emailTokenRequired = $state(false)
|
||||
let emailUpdateAuthorized = $state(false)
|
||||
let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||
let newEmailInUse = $state(false)
|
||||
|
||||
async function checkNewEmailInUse() {
|
||||
if (!newEmail.trim() || !newEmail.includes('@')) {
|
||||
newEmailInUse = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api.checkEmailInUse(newEmail.trim())
|
||||
newEmailInUse = result.inUse
|
||||
} catch {
|
||||
newEmailInUse = false
|
||||
}
|
||||
}
|
||||
let handleLoading = $state(false)
|
||||
let newHandle = $state('')
|
||||
let deleteLoading = $state(false)
|
||||
@@ -543,10 +557,14 @@
|
||||
id="new-email"
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
onblur={checkNewEmailInUse}
|
||||
placeholder={$_('settings.newEmailPlaceholder')}
|
||||
disabled={emailLoading || emailUpdateAuthorized}
|
||||
required
|
||||
/>
|
||||
{#if newEmailInUse}
|
||||
<p class="hint warning">{$_('settings.emailInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}>
|
||||
@@ -565,10 +583,14 @@
|
||||
id="new-email"
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
onblur={checkNewEmailInUse}
|
||||
placeholder={$_('settings.newEmailPlaceholder')}
|
||||
disabled={emailLoading}
|
||||
required
|
||||
/>
|
||||
{#if newEmailInUse}
|
||||
<p class="hint warning">{$_('settings.emailInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="submit" disabled={emailLoading || !newEmail.trim()}>
|
||||
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
|
||||
|
||||
+160
-181
@@ -20,6 +20,16 @@
|
||||
signal: boolean
|
||||
}
|
||||
|
||||
interface RegistrationResult {
|
||||
did: string
|
||||
handle: string
|
||||
redirectUrl: string
|
||||
accessJwt?: string
|
||||
refreshJwt?: string
|
||||
appPassword?: string
|
||||
appPasswordName?: string
|
||||
}
|
||||
|
||||
let pending = $state<PendingRegistration | null>(null)
|
||||
let loading = $state(true)
|
||||
let submitting = $state(false)
|
||||
@@ -38,9 +48,13 @@
|
||||
let checkingHandle = $state(false)
|
||||
let handleError = $state<string | null>(null)
|
||||
|
||||
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
|
||||
let externalDid = $state('')
|
||||
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
selfHostedDidWebEnabled: boolean
|
||||
} | null>(null)
|
||||
|
||||
let commsChannels = $state<CommsChannelConfig>({
|
||||
@@ -50,6 +64,11 @@
|
||||
signal: false,
|
||||
})
|
||||
|
||||
let showAppPassword = $state(false)
|
||||
let registrationResult = $state<RegistrationResult | null>(null)
|
||||
let appPasswordCopied = $state(false)
|
||||
let appPasswordAcknowledged = $state(false)
|
||||
|
||||
function getToken(): string | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('token')
|
||||
@@ -70,6 +89,10 @@
|
||||
return commsChannels[ch as keyof CommsChannelConfig] ?? false
|
||||
}
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
@@ -89,6 +112,7 @@
|
||||
serverInfo = {
|
||||
availableUserDomains: data.availableUserDomains || [],
|
||||
inviteCodeRequired: data.inviteCodeRequired ?? false,
|
||||
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
|
||||
}
|
||||
if (data.commsChannels) {
|
||||
commsChannels = {
|
||||
@@ -191,6 +215,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function copyAppPassword() {
|
||||
if (registrationResult?.appPassword) {
|
||||
navigator.clipboard.writeText(registrationResult.appPassword)
|
||||
appPasswordCopied = true
|
||||
}
|
||||
}
|
||||
|
||||
function proceedFromAppPassword() {
|
||||
if (!registrationResult) return
|
||||
|
||||
if (registrationResult.accessJwt && registrationResult.refreshJwt) {
|
||||
localStorage.setItem('accessJwt', registrationResult.accessJwt)
|
||||
localStorage.setItem('refreshJwt', registrationResult.refreshJwt)
|
||||
}
|
||||
|
||||
if (registrationResult.redirectUrl) {
|
||||
if (registrationResult.redirectUrl.startsWith('/app/verify')) {
|
||||
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
|
||||
did: registrationResult.did,
|
||||
handle: registrationResult.handle,
|
||||
channel: verificationChannel,
|
||||
}))
|
||||
const url = new URL(registrationResult.redirectUrl, window.location.origin)
|
||||
url.searchParams.set('handle', registrationResult.handle)
|
||||
url.searchParams.set('channel', verificationChannel)
|
||||
window.location.href = url.pathname + url.search
|
||||
return
|
||||
}
|
||||
window.location.href = registrationResult.redirectUrl
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const token = getToken()
|
||||
@@ -229,6 +285,8 @@
|
||||
discord_id: discordId || null,
|
||||
telegram_username: telegramUsername || null,
|
||||
signal_number: signalNumber || null,
|
||||
did_type: didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : null,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -240,36 +298,34 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (data.accessJwt && data.refreshJwt) {
|
||||
localStorage.setItem('accessJwt', data.accessJwt)
|
||||
localStorage.setItem('refreshJwt', data.refreshJwt)
|
||||
registrationResult = {
|
||||
did: data.did,
|
||||
handle: data.handle,
|
||||
redirectUrl: data.redirectUrl,
|
||||
accessJwt: data.accessJwt,
|
||||
refreshJwt: data.refreshJwt,
|
||||
appPassword: data.appPassword,
|
||||
appPasswordName: data.appPasswordName,
|
||||
}
|
||||
|
||||
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
|
||||
if (registrationResult.appPassword) {
|
||||
showAppPassword = true
|
||||
submitting = false
|
||||
} else {
|
||||
proceedFromAppPassword()
|
||||
}
|
||||
|
||||
toast.error($_('common.error'))
|
||||
submitting = false
|
||||
} catch {
|
||||
toast.error($_('common.error'))
|
||||
} catch (err) {
|
||||
console.error('SSO registration failed:', err)
|
||||
toast.error(err instanceof Error ? err.message : $_('common.error'))
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sso-register-container">
|
||||
<div class="page">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="spinner md"></div>
|
||||
<p>{$_('common.loading')}</p>
|
||||
</div>
|
||||
{:else if error && !pending}
|
||||
@@ -277,7 +333,40 @@
|
||||
<div class="error-icon">!</div>
|
||||
<h2>{$_('common.error')}</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
|
||||
<a href="/app/oauth/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
|
||||
</div>
|
||||
{:else if showAppPassword && registrationResult}
|
||||
<header class="page-header">
|
||||
<h1>{$_('appPasswords.created')}</h1>
|
||||
<p class="subtitle">{$_('appPasswords.createdMessage')}</p>
|
||||
</header>
|
||||
|
||||
<div class="app-password-step">
|
||||
<div class="warning-box">
|
||||
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
|
||||
<p>{$_('appPasswords.saveWarningMessage')}</p>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">
|
||||
App Password for: <strong>{registrationResult.appPasswordName}</strong>
|
||||
</div>
|
||||
<code class="app-password-code">{registrationResult.appPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyAppPassword}>
|
||||
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
|
||||
<span>{$_('appPasswords.acknowledgeLabel')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick={proceedFromAppPassword} disabled={!appPasswordAcknowledged}>
|
||||
{$_('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if pending}
|
||||
<header class="page-header">
|
||||
@@ -404,6 +493,56 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{$_('registerPasskey.identityType')}</legend>
|
||||
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
|
||||
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWeb')}</strong>
|
||||
{#if serverInfo?.selfHostedDidWebEnabled === false}
|
||||
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
|
||||
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
|
||||
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
|
||||
@@ -438,177 +577,17 @@
|
||||
</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>
|
||||
@@ -151,12 +151,12 @@
|
||||
placeholder={$_('trustedDevices.deviceNamePlaceholder')}
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-small btn-primary" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
|
||||
<button class="btn-small btn-secondary" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
|
||||
<button class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
|
||||
<button class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
|
||||
<button class="btn-icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
|
||||
<button class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
|
||||
✎
|
||||
</button>
|
||||
{/if}
|
||||
@@ -192,7 +192,7 @@
|
||||
</div>
|
||||
|
||||
<div class="device-actions">
|
||||
<button class="btn-danger" onclick={() => handleRevoke(device.id)}>
|
||||
<button class="sm danger-outline" onclick={() => handleRevoke(device.id)}>
|
||||
{$_('trustedDevices.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -203,12 +203,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
@@ -300,19 +294,6 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@@ -338,63 +319,9 @@
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
let successPurpose = $state<string | null>(null)
|
||||
let successChannel = $state<string | null>(null)
|
||||
let tokenFromUrl = $state(false)
|
||||
let oauthRequestUri = $state<string | null>(null)
|
||||
|
||||
const auth = $derived(getAuthState())
|
||||
|
||||
@@ -70,6 +71,9 @@
|
||||
}
|
||||
} else {
|
||||
mode = 'signup'
|
||||
if (params.request_uri) {
|
||||
oauthRequestUri = params.request_uri
|
||||
}
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
@@ -83,6 +87,18 @@
|
||||
pendingVerification = null
|
||||
}
|
||||
}
|
||||
if (!pendingVerification && params.did && params.handle && params.channel) {
|
||||
pendingVerification = {
|
||||
did: unsafeAsDid(params.did),
|
||||
handle: params.handle,
|
||||
channel: params.channel,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
did: params.did,
|
||||
handle: params.handle,
|
||||
channel: params.channel,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -93,6 +109,34 @@
|
||||
}
|
||||
})
|
||||
|
||||
let pollingVerification = false
|
||||
$effect(() => {
|
||||
if (mode === 'signup' && pendingVerification && !verificationCode.trim()) {
|
||||
const currentPending = pendingVerification
|
||||
const interval = setInterval(async () => {
|
||||
if (pollingVerification || verificationCode.trim()) return
|
||||
pollingVerification = true
|
||||
try {
|
||||
const result = await api.checkEmailVerified(currentPending.did)
|
||||
if (result.verified) {
|
||||
clearInterval(interval)
|
||||
clearPendingVerification()
|
||||
if (oauthRequestUri) {
|
||||
navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } })
|
||||
} else {
|
||||
navigate(routes.login)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
pollingVerification = false
|
||||
}
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function clearPendingVerification() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
pendingVerification = null
|
||||
@@ -108,7 +152,11 @@
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
clearPendingVerification()
|
||||
navigate('/dashboard')
|
||||
if (oauthRequestUri) {
|
||||
navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } })
|
||||
} else {
|
||||
navigate(routes.dashboard)
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Verification failed'
|
||||
} finally {
|
||||
|
||||
@@ -194,6 +194,38 @@ button.ghost:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
button.link:hover:not(:disabled) {
|
||||
background: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button.sm {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
button.icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-1);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
button.icon:hover:not(:disabled) {
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
@@ -281,6 +313,10 @@ hr {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.hint.success {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -372,6 +408,32 @@ hr {
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.loading p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--text-secondary);
|
||||
@@ -510,6 +572,11 @@ hr {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner.md {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.spinner.lg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@@ -521,3 +588,308 @@ hr {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-normal);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
margin-top: var(--space-1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.radio-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.radio-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radio-label.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio-hint.disabled-hint {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin: var(--space-4) 0 0 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.warning-box li:last-child {
|
||||
margin-bottom: 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;
|
||||
}
|
||||
|
||||
.app-password-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
max-width: var(--width-md);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-password-step .warning-box {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.app-password-step .warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-links {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.form-links .link-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-links .link-text a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.contact-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.contact-fields .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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: var(--text-xl);
|
||||
font-weight: var(--font-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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
@@ -445,12 +445,6 @@ label.auth-option {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.passkey-section {
|
||||
margin-top: var(--space-5);
|
||||
text-align: center;
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.25s ease;
|
||||
|
||||
--z-modal: 1000;
|
||||
--overlay-bg: rgba(0, 0, 0, 0.5);
|
||||
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
|
||||
|
||||
--bg-primary: #f9fafa;
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
loadDPoPKey,
|
||||
prepareWebAuthnCreationOptions,
|
||||
saveDPoPKey,
|
||||
} from "../../lib/migration/atproto-client";
|
||||
import type { OAuthServerMetadata } from "../../lib/migration/types";
|
||||
} from "../../lib/migration/atproto-client.ts";
|
||||
import type { OAuthServerMetadata } from "../../lib/migration/types.ts";
|
||||
|
||||
const DPOP_KEY_STORAGE = "migration_dpop_key";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte";
|
||||
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte.ts";
|
||||
|
||||
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlcOps, plcOps } from "../../lib/migration/plc-ops";
|
||||
import { PlcOps, plcOps } from "../../lib/migration/plc-ops.ts";
|
||||
|
||||
describe("migration/plc-ops", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -83,6 +83,7 @@ function createInboundState(
|
||||
authMethod: "password",
|
||||
passkeySetupToken: null,
|
||||
oauthCodeVerifier: null,
|
||||
localAccessToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MigrationError } from "../../lib/migration/types";
|
||||
import { MigrationError } from "../../lib/migration/types.ts";
|
||||
|
||||
describe("migration/types", () => {
|
||||
describe("MigrationError", () => {
|
||||
|
||||
@@ -84,7 +84,9 @@ export function getErrorToasts(): string[] {
|
||||
export { getToasts, toast };
|
||||
function extractEndpoint(url: string): string {
|
||||
const match = url.match(/\/xrpc\/([^?]+)/);
|
||||
return match ? match[1] : url;
|
||||
if (match) return match[1];
|
||||
const pathOnly = url.split("?")[0];
|
||||
return pathOnly;
|
||||
}
|
||||
export function setupFetchMock(): void {
|
||||
globalThis.fetch = vi.fn(
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
||||
import {
|
||||
clearMocks,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
mockEndpoint,
|
||||
setupFetchMock,
|
||||
} from "./mocks.ts";
|
||||
import { _testSetState } from "../lib/auth.svelte.ts";
|
||||
|
||||
function createMockIndexedDB() {
|
||||
const stores: Map<string, Map<string, unknown>> = new Map();
|
||||
|
||||
return {
|
||||
open: vi.fn((_name: string, _version?: number) => {
|
||||
const createTransaction = (_storeName: string, _mode?: string) => {
|
||||
const tx = {
|
||||
objectStore: (name: string) => {
|
||||
if (!stores.has(name)) {
|
||||
stores.set(name, new Map());
|
||||
}
|
||||
const store = stores.get(name)!;
|
||||
return {
|
||||
put: (value: unknown, key: string) => {
|
||||
store.set(key, value);
|
||||
return { result: undefined };
|
||||
},
|
||||
get: (key: string) => ({
|
||||
result: store.get(key),
|
||||
}),
|
||||
};
|
||||
},
|
||||
oncomplete: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
setTimeout(() => tx.oncomplete?.(), 0);
|
||||
return tx;
|
||||
};
|
||||
|
||||
const request = {
|
||||
result: {
|
||||
objectStoreNames: { contains: () => true },
|
||||
createObjectStore: vi.fn(),
|
||||
transaction: createTransaction,
|
||||
close: vi.fn(),
|
||||
},
|
||||
error: null,
|
||||
onsuccess: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
onupgradeneeded: null as (() => void) | null,
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
request.onupgradeneeded?.();
|
||||
request.onsuccess?.();
|
||||
}, 0);
|
||||
|
||||
return request;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("OAuth Registration Flow", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
|
||||
createMockIndexedDB();
|
||||
|
||||
Object.defineProperty(globalThis.location, "search", {
|
||||
value: "",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/register",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "origin", {
|
||||
value: "http://localhost:3000",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
value: "http://localhost:3000/app/register",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
_testSetState({
|
||||
session: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
savedAccounts: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("startOAuthRegister", () => {
|
||||
it("calls PAR endpoint with prompt=create", async () => {
|
||||
let capturedBody: string | null = null;
|
||||
|
||||
mockEndpoint("/oauth/par", (_url, options) => {
|
||||
capturedBody = options?.body as string;
|
||||
return jsonResponse(
|
||||
{ request_uri: "urn:mock:request", expires_in: 60 },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
const { startOAuthRegister } = await import("../lib/oauth.ts");
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: hrefSetter,
|
||||
get: () => "http://localhost:3000/app/register",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await startOAuthRegister();
|
||||
|
||||
expect(capturedBody).not.toBeNull();
|
||||
const params = new URLSearchParams(capturedBody!);
|
||||
expect(params.get("prompt")).toBe("create");
|
||||
expect(params.get("response_type")).toBe("code");
|
||||
expect(params.get("scope")).toContain("atproto");
|
||||
});
|
||||
|
||||
it("redirects to authorize endpoint after PAR", async () => {
|
||||
mockEndpoint("/oauth/par", () =>
|
||||
jsonResponse(
|
||||
{ request_uri: "urn:mock:test-request-uri", expires_in: 60 },
|
||||
201,
|
||||
));
|
||||
|
||||
const { startOAuthRegister } = await import("../lib/oauth.ts");
|
||||
|
||||
let redirectUrl: string | null = null;
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => "http://localhost:3000/app/register",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await startOAuthRegister();
|
||||
|
||||
expect(redirectUrl).not.toBeNull();
|
||||
expect(redirectUrl).toContain("/oauth/authorize");
|
||||
expect(redirectUrl).toContain("request_uri=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Register (passkey) component", () => {
|
||||
it("adds request_uri to URL when none present", async () => {
|
||||
mockEndpoint("/oauth/par", () =>
|
||||
jsonResponse(
|
||||
{ request_uri: "urn:mock:request", expires_in: 60 },
|
||||
201,
|
||||
));
|
||||
|
||||
let redirectUrl: string | null = null;
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => "http://localhost:3000/app/register",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(redirectUrl).not.toBeNull();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(redirectUrl).toContain("request_uri=");
|
||||
});
|
||||
|
||||
it("shows loading state while fetching request_uri", async () => {
|
||||
mockEndpoint("/oauth/par", () =>
|
||||
jsonResponse(
|
||||
{ request_uri: "urn:mock:request", expires_in: 60 },
|
||||
201,
|
||||
));
|
||||
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: () => {},
|
||||
get: () => "http://localhost:3000/app/register",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("logs error if OAuth initiation fails", async () => {
|
||||
mockEndpoint(
|
||||
"/oauth/par",
|
||||
() =>
|
||||
jsonResponse({
|
||||
error: "invalid_request",
|
||||
error_description: "Test error",
|
||||
}, 400),
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: () => {},
|
||||
get: () => "http://localhost:3000/app/register",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to ensure OAuth request URI"),
|
||||
expect.anything(),
|
||||
);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegisterPassword component", () => {
|
||||
it("adds request_uri to URL when none present", async () => {
|
||||
mockEndpoint("/oauth/par", () =>
|
||||
jsonResponse(
|
||||
{ request_uri: "urn:mock:request", expires_in: 60 },
|
||||
201,
|
||||
));
|
||||
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/oauth/register-password",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let redirectUrl: string | null = null;
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => "http://localhost:3000/app/oauth/register-password",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const RegisterPassword =
|
||||
(await import("../routes/RegisterPassword.svelte")).default;
|
||||
render(RegisterPassword);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(redirectUrl).not.toBeNull();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(redirectUrl).toContain("request_uri=");
|
||||
});
|
||||
|
||||
it("renders form when request_uri is present", async () => {
|
||||
Object.defineProperty(globalThis.location, "search", {
|
||||
value: "?request_uri=urn:mock:test-request",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/oauth/register-password",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockEndpoint(
|
||||
"com.atproto.server.describeServer",
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"/oauth/sso/providers",
|
||||
() => jsonResponse({ providers: [] }),
|
||||
);
|
||||
|
||||
const RegisterPassword =
|
||||
(await import("../routes/RegisterPassword.svelte")).default;
|
||||
render(RegisterPassword);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/handle/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegisterSso component", () => {
|
||||
it("adds request_uri to URL when none present", async () => {
|
||||
mockEndpoint("/oauth/par", () =>
|
||||
jsonResponse(
|
||||
{ request_uri: "urn:mock:request", expires_in: 60 },
|
||||
201,
|
||||
));
|
||||
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/register-sso",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let redirectUrl: string | null = null;
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => "http://localhost:3000/app/register-sso",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const RegisterSso =
|
||||
(await import("../routes/RegisterSso.svelte")).default;
|
||||
render(RegisterSso);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(redirectUrl).not.toBeNull();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(redirectUrl).toContain("request_uri=");
|
||||
});
|
||||
|
||||
it("renders SSO providers when request_uri is present", async () => {
|
||||
Object.defineProperty(globalThis.location, "search", {
|
||||
value: "?request_uri=urn:mock:test-request",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/register-sso",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockEndpoint("/oauth/sso/providers", () =>
|
||||
jsonResponse({
|
||||
providers: [{ provider: "google", name: "Google", icon: "google" }],
|
||||
}));
|
||||
|
||||
const RegisterSso =
|
||||
(await import("../routes/RegisterSso.svelte")).default;
|
||||
render(RegisterSso);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/google/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes request_uri when initiating SSO registration", async () => {
|
||||
Object.defineProperty(globalThis.location, "search", {
|
||||
value: "?request_uri=urn:mock:test-request-uri",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/register-sso",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockEndpoint("/oauth/sso/providers", () =>
|
||||
jsonResponse({
|
||||
providers: [{ provider: "google", name: "Google", icon: "google" }],
|
||||
}));
|
||||
|
||||
let capturedBody: string | null = null;
|
||||
mockEndpoint("/oauth/sso/initiate", (_url, options) => {
|
||||
capturedBody = options?.body as string;
|
||||
return jsonResponse({ redirect_url: "https://google.com/oauth" });
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis.location, "href", {
|
||||
set: () => {},
|
||||
get: () =>
|
||||
"http://localhost:3000/app/register-sso?request_uri=urn:mock:test-request-uri",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const RegisterSso =
|
||||
(await import("../routes/RegisterSso.svelte")).default;
|
||||
render(RegisterSso);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/google/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const googleButton = screen.getByRole("button", { name: /google/i });
|
||||
googleButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
});
|
||||
|
||||
const body = JSON.parse(capturedBody!);
|
||||
expect(body.request_uri).toBe("urn:mock:test-request-uri");
|
||||
expect(body.action).toBe("register");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountTypeSwitcher with OAuth context", () => {
|
||||
it("preserves request_uri in links when oauthRequestUri is provided", async () => {
|
||||
const AccountTypeSwitcher = (
|
||||
await import("../components/AccountTypeSwitcher.svelte")
|
||||
).default;
|
||||
|
||||
render(AccountTypeSwitcher, {
|
||||
props: {
|
||||
active: "passkey",
|
||||
ssoAvailable: true,
|
||||
oauthRequestUri: "urn:mock:test-request-uri",
|
||||
},
|
||||
});
|
||||
|
||||
const passwordLink = screen.getByText(/password/i).closest("a");
|
||||
const ssoLink = screen.getByText(/sso/i).closest("a");
|
||||
|
||||
expect(passwordLink?.getAttribute("href")).toContain("request_uri=");
|
||||
expect(passwordLink?.getAttribute("href")).toContain(
|
||||
encodeURIComponent("urn:mock:test-request-uri"),
|
||||
);
|
||||
expect(ssoLink?.getAttribute("href")).toContain("request_uri=");
|
||||
});
|
||||
|
||||
it("uses oauth routes without request_uri when no oauthRequestUri provided", async () => {
|
||||
const AccountTypeSwitcher = (
|
||||
await import("../components/AccountTypeSwitcher.svelte")
|
||||
).default;
|
||||
|
||||
render(AccountTypeSwitcher, {
|
||||
props: {
|
||||
active: "passkey",
|
||||
ssoAvailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
const passwordLink = screen.getByText(/password/i).closest("a");
|
||||
expect(passwordLink?.getAttribute("href")).toBe(
|
||||
"/app/oauth/register-password",
|
||||
);
|
||||
expect(passwordLink?.getAttribute("href")).not.toContain("request_uri=");
|
||||
});
|
||||
|
||||
it("passkey link goes to oauth/register when in OAuth context", async () => {
|
||||
const AccountTypeSwitcher = (
|
||||
await import("../components/AccountTypeSwitcher.svelte")
|
||||
).default;
|
||||
|
||||
render(AccountTypeSwitcher, {
|
||||
props: {
|
||||
active: "password",
|
||||
ssoAvailable: true,
|
||||
oauthRequestUri: "urn:mock:test-request-uri",
|
||||
},
|
||||
});
|
||||
|
||||
const passkeyLink = screen.getByText(/passkey/i).closest("a");
|
||||
expect(passkeyLink?.getAttribute("href")).toContain("/oauth/register");
|
||||
expect(passkeyLink?.getAttribute("href")).toContain("request_uri=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Register component (OAuth context)", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis.location, "search", {
|
||||
value: "?request_uri=urn:mock:test-request",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: "/app/oauth/register",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockEndpoint(
|
||||
"com.atproto.server.describeServer",
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"/oauth/sso/providers",
|
||||
() => jsonResponse({ providers: [] }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"/oauth/authorize",
|
||||
() => jsonResponse({ client_name: "Test App" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders registration form with AccountTypeSwitcher", async () => {
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(() => {
|
||||
const switcher = document.querySelector(".account-type-switcher");
|
||||
expect(switcher).toBeInTheDocument();
|
||||
expect(switcher?.textContent).toContain("Passkey");
|
||||
expect(switcher?.textContent).toContain("Password");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays client name in subtitle when available", async () => {
|
||||
mockEndpoint(
|
||||
"/oauth/authorize",
|
||||
() => jsonResponse({ client_name: "Awesome App" }),
|
||||
);
|
||||
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/awesome app/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows handle input field", async () => {
|
||||
const Register = (await import("../routes/Register.svelte"))
|
||||
.default;
|
||||
render(Register);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/handle/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { init, register, waitLocale } from "svelte-i18n";
|
||||
import { _testResetState } from "../lib/auth.svelte";
|
||||
import { _testResetState } from "../lib/auth.svelte.ts";
|
||||
|
||||
register("en", () => import("../locales/en.json"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user