feat: cross-pds delegation

This commit is contained in:
Lewis
2026-03-17 19:22:34 +00:00
committed by Tangled
parent d5ed420dd7
commit 7926c798c6
50 changed files with 2077 additions and 1701 deletions
@@ -17,10 +17,11 @@
interface Controller {
did: Did
handle: Handle
handle?: Handle
grantedScopes: ScopeSet
grantedAt: string
isActive: boolean
isLocal: boolean
}
interface ControlledAccount {
@@ -48,10 +49,72 @@
let canControlAccounts = $derived(!hasControllers)
let showAddController = $state(false)
let addControllerDid = $state('')
let addControllerIdentifier = $state('')
let addControllerScopes = $state('atproto')
let addingController = $state(false)
let addControllerConfirmed = $state(false)
let resolvedController = $state<{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean } | null>(null)
let resolving = $state(false)
let resolveError = $state('')
let typeaheadResults = $state<Array<{ did: string; handle: string; displayName?: string; avatar?: string }>>([])
let typeaheadTimeout: ReturnType<typeof setTimeout> | null = null
let showTypeahead = $state(false)
function onControllerInput(value: string) {
addControllerIdentifier = value
resolvedController = null
resolveError = ''
if (typeaheadTimeout) clearTimeout(typeaheadTimeout)
const trimmed = value.trim().replace(/^@/, '')
if (trimmed.startsWith('did:') || trimmed.length < 2) {
typeaheadResults = []
showTypeahead = false
return
}
typeaheadTimeout = setTimeout(async () => {
const resp = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(trimmed)}&limit=5`
)
if (resp.ok) {
const data = await resp.json()
typeaheadResults = (data.actors ?? []).map((a: Record<string, unknown>) => ({
did: a.did as string,
handle: a.handle as string,
displayName: a.displayName as string | undefined,
avatar: a.avatar as string | undefined,
}))
showTypeahead = typeaheadResults.length > 0
}
}, 200)
}
function selectTypeahead(actor: { did: string; handle: string }) {
addControllerIdentifier = actor.handle
showTypeahead = false
typeaheadResults = []
resolveControllerIdentifier()
}
async function resolveControllerIdentifier() {
const identifier = addControllerIdentifier.trim().replace(/^@/, '')
if (!identifier) return
resolving = true
resolveError = ''
resolvedController = null
const result = await api.resolveController(identifier)
if (result.ok) {
resolvedController = result.value
} else {
resolveError = $_('delegation.controllerNotFound')
}
resolving = false
}
let showCreateDelegated = $state(false)
let newDelegatedHandle = $state('')
@@ -77,7 +140,8 @@
handle: c.handle,
grantedScopes: c.grantedScopes,
grantedAt: c.grantedAt,
isActive: c.isActive
isActive: c.isActive,
isLocal: c.isLocal
}))
}
}
@@ -107,17 +171,18 @@
}
async function addController() {
if (!addControllerDid.trim()) return
if (!resolvedController) return
addingController = true
const controllerDid = unsafeAsDid(addControllerDid.trim())
const controllerDid = unsafeAsDid(resolvedController.did)
const scopes = unsafeAsScopeSet(addControllerScopes)
const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes)
if (result.ok) {
toast.success($_('delegation.controllerAdded'))
addControllerDid = ''
addControllerIdentifier = ''
addControllerScopes = 'atproto'
addControllerConfirmed = false
resolvedController = null
showAddController = false
await loadControllers()
}
@@ -182,7 +247,7 @@
<div class="item-card" class:inactive={!controller.isActive}>
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{controller.handle || controller.did}</span>
<span class="item-handle">{controller.handle ? `@${controller.handle}` : controller.did}</span>
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
{#if !controller.isActive}
<span class="badge inactive">{$_('delegation.inactive')}</span>
@@ -227,15 +292,52 @@
</ul>
</div>
<div class="field">
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
<input
id="controllerDid"
type="text"
bind:value={addControllerDid}
placeholder="did:plc:..."
disabled={addingController}
/>
<div class="field controller-search">
<label for="controllerIdentifier">{$_('delegation.controllerIdentifier')}</label>
<div class="search-wrapper">
<input
id="controllerIdentifier"
type="text"
value={addControllerIdentifier}
oninput={(e) => onControllerInput((e.target as HTMLInputElement).value)}
onblur={() => { setTimeout(() => { showTypeahead = false }, 200) }}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); showTypeahead = false; resolveControllerIdentifier() } }}
placeholder="handle or did:plc:..."
disabled={addingController}
/>
{#if showTypeahead && typeaheadResults.length > 0}
<div class="typeahead-dropdown">
{#each typeaheadResults as actor}
<button type="button" class="typeahead-item" onmousedown={() => selectTypeahead(actor)}>
{#if actor.avatar}
<img src={actor.avatar} alt="" class="typeahead-avatar" />
{/if}
<div class="typeahead-text">
{#if actor.displayName}
<span class="typeahead-name">{actor.displayName}</span>
{/if}
<span class="typeahead-handle">@{actor.handle}</span>
</div>
</button>
{/each}
</div>
{/if}
</div>
{#if resolving}
<span class="resolve-status">{$_('common.loading')}</span>
{:else if resolveError}
<span class="resolve-status error">{resolveError}</span>
{:else if resolvedController}
<div class="resolved-info">
<span class="resolved-did">{resolvedController.did}</span>
{#if resolvedController.handle}
<span class="resolved-handle">@{resolvedController.handle}</span>
{/if}
{#if !resolvedController.isLocal && resolvedController.pdsUrl}
<span class="badge external">{new URL(resolvedController.pdsUrl).hostname}</span>
{/if}
</div>
{/if}
</div>
<div class="field">
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
@@ -253,7 +355,7 @@
<button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
{$_('common.cancel')}
</button>
<button type="button" onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
<button type="button" onclick={addController} disabled={addingController || !resolvedController || !addControllerConfirmed}>
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
</button>
</div>
@@ -636,6 +738,114 @@
justify-content: flex-end;
}
.controller-search {
position: relative;
}
.search-wrapper {
position: relative;
}
.typeahead-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 240px;
overflow-y: auto;
}
.typeahead-item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: transparent;
cursor: pointer;
text-align: left;
color: var(--text-primary);
}
.typeahead-item:hover {
background: var(--bg-tertiary);
}
.typeahead-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.typeahead-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.typeahead-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.typeahead-handle {
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.resolve-status {
display: block;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: var(--space-1);
}
.resolve-status.error {
color: var(--error-text);
}
.resolved-info {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
font-size: var(--text-xs);
}
.resolved-did {
font-family: var(--font-mono);
color: var(--text-secondary);
word-break: break-all;
}
.resolved-handle {
color: var(--text-primary);
font-weight: var(--font-medium);
}
.badge.external {
background: var(--info-bg, var(--bg-tertiary));
color: var(--info-text, var(--text-secondary));
border: 1px solid var(--info-border, var(--border-color));
}
@media (max-width: 600px) {
.item-card {
flex-direction: column;
+15 -1
View File
@@ -326,7 +326,7 @@ function _castDelegationController(raw: unknown): DelegationController {
const c = raw as Record<string, unknown>;
return {
did: unsafeAsDid(c.did as string),
handle: unsafeAsHandle(c.handle as string),
handle: c.handle ? unsafeAsHandle(c.handle as string) : undefined,
grantedScopes: unsafeAsScopeSet(
(c.granted_scopes ?? c.grantedScopes) as string,
),
@@ -334,6 +334,7 @@ function _castDelegationController(raw: unknown): DelegationController {
(c.granted_at ?? c.grantedAt ?? c.added_at) as string,
),
isActive: (c.is_active ?? c.isActive ?? true) as boolean,
isLocal: (c.is_local ?? c.isLocal ?? true) as boolean,
};
}
@@ -1471,6 +1472,19 @@ export const api = {
return xrpcResult("_delegation.getScopePresets");
},
resolveController(
identifier: string,
): Promise<
Result<
{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean },
ApiError
>
> {
return xrpcResult("_delegation.resolveController", {
params: { identifier },
});
},
addDelegationController(
token: AccessToken,
controllerDid: Did,
+2 -1
View File
@@ -570,10 +570,11 @@ export interface SsoLinkedAccount {
export interface DelegationController {
did: Did;
handle: Handle;
handle?: Handle;
grantedScopes: ScopeSet;
grantedAt: ISODateString;
isActive: boolean;
isLocal: boolean;
}
export interface DelegationControlledAccount {
+6 -1
View File
@@ -569,9 +569,13 @@
"required": "Required",
"rememberChoiceLabel": "Remember my choice for this application",
"scopes": {
"atproto": {
"name": "AT Protocol Access",
"description": "Identity verification and session establishment"
},
"atprotoWithGranular": {
"name": "AT Protocol Access",
"description": "AT Protocol baseline scope (permissions determined by selected options below)"
"description": "AT Protocol baseline (permissions determined by selected options below)"
}
},
"unexpectedState": {
@@ -818,6 +822,7 @@
"cannotAddControllers": "You cannot add controllers because this account controls other accounts. An account can either have controllers or control other accounts, but not both.",
"addController": "Add Controller",
"controllerDid": "Controller DID",
"controllerIdentifier": "Controller handle or DID",
"accessLevel": "Access Level",
"adding": "Adding...",
"addControllerButton": "+ Add Controller",
+4
View File
@@ -575,6 +575,10 @@
"required": "Vaaditaan",
"rememberChoiceLabel": "Muista valintani tälle sovellukselle",
"scopes": {
"atproto": {
"name": "AT Protocol -käyttöoikeus",
"description": "Henkilöllisyyden varmennus ja istunnon muodostus"
},
"atprotoWithGranular": {
"name": "AT Protocol -käyttöoikeus",
"description": "AT Protocol -peruslaajuus (oikeudet määräytyvät alla valittujen vaihtoehtojen mukaan)"
+4
View File
@@ -575,6 +575,10 @@
"required": "必須",
"rememberChoiceLabel": "このアプリに対する選択を記憶する",
"scopes": {
"atproto": {
"name": "AT Protocol アクセス",
"description": "本人確認とセッション確立"
},
"atprotoWithGranular": {
"name": "AT Protocol アクセス",
"description": "AT Protocol 基本スコープ(権限は以下で選択したオプションによって決まります)"
+4
View File
@@ -575,6 +575,10 @@
"required": "필수",
"rememberChoiceLabel": "이 앱에 대한 선택 기억하기",
"scopes": {
"atproto": {
"name": "AT Protocol 액세스",
"description": "신원 확인 및 세션 설정"
},
"atprotoWithGranular": {
"name": "AT Protocol 액세스",
"description": "AT Protocol 기본 범위 (권한은 아래 선택한 옵션에 의해 결정됨)"
+4
View File
@@ -575,6 +575,10 @@
"required": "Krävs",
"rememberChoiceLabel": "Kom ihåg mitt val för denna applikation",
"scopes": {
"atproto": {
"name": "AT Protocol-åtkomst",
"description": "Identitetsverifiering och sessionsupprättande"
},
"atprotoWithGranular": {
"name": "AT Protocol-åtkomst",
"description": "AT Protocol basomfattning (behörigheter bestäms av valda alternativ nedan)"
+4
View File
@@ -575,6 +575,10 @@
"required": "必需",
"rememberChoiceLabel": "记住对此应用的授权选择",
"scopes": {
"atproto": {
"name": "AT Protocol 访问",
"description": "身份验证和会话建立"
},
"atprotoWithGranular": {
"name": "AT Protocol 访问",
"description": "AT Protocol 基础范围(权限由下方选择的选项决定)"
+13 -420
View File
@@ -1,29 +1,12 @@
<script lang="ts">
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import {
prepareRequestOptions,
serializeAssertionResponse,
type WebAuthnRequestOptionsResponse,
} from '../lib/webauthn'
let delegatedDid = $state<string | null>(null)
let delegatedHandle = $state<string | null>(null)
let controllerIdentifier = $state('')
let controllerDid = $state<string | null>(null)
let password = $state('')
let rememberDevice = $state(false)
let submitting = $state(false)
let loading = $state(true)
let error = $state<string | null>(null)
let hasPasskeys = $state(false)
let hasTotp = $state(false)
let passkeySupported = $state(false)
let step = $state<'identifier' | 'password'>('identifier')
$effect(() => {
passkeySupported = window.PublicKeyCredential !== undefined
})
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
@@ -50,18 +33,12 @@
}
try {
const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`)
const response = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`)
if (response.ok) {
const data = await response.json()
delegatedHandle = data.handle || delegatedDid
} else {
const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`)
if (handleResponse.ok) {
const data = await handleResponse.json()
delegatedHandle = data.handle || delegatedDid
} else {
delegatedHandle = delegatedDid
}
delegatedHandle = delegatedDid
}
} catch {
delegatedHandle = delegatedDid
@@ -70,7 +47,7 @@
}
}
async function handleIdentifierSubmit(e: Event) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!controllerIdentifier.trim()) return
@@ -91,128 +68,13 @@
resolvedDid = data.did
}
controllerDid = resolvedDid
const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`)
if (securityResponse.ok) {
const data = await securityResponse.json()
hasPasskeys = passkeySupported && data.hasPasskeys === true
hasTotp = data.hasTotp === true
}
step = 'password'
} catch {
error = $_('oauthDelegation.controllerNotFound')
} finally {
submitting = false
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !controllerDid || !delegatedDid) {
error = $_('oauthDelegation.missingInfo')
return
}
submitting = true
error = null
try {
const startResponse = await fetch('/oauth/passkey/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: controllerIdentifier.trim().replace(/^@/, ''),
delegated_did: delegatedDid
})
})
if (!startResponse.ok) {
const data = await startResponse.json()
error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart')
const requestUri = getRequestUri()
if (!requestUri || !delegatedDid) {
error = $_('oauthDelegation.missingInfo')
submitting = false
return
}
const { options } = await startResponse.json()
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
}) as PublicKeyCredential | null
if (!credential) {
error = $_('oauthDelegation.passkeyCancelled')
submitting = false
return
}
const credentialData = serializeAssertionResponse(credential)
const finishResponse = await fetch('/oauth/passkey/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: controllerIdentifier.trim().replace(/^@/, ''),
credential: credentialData,
delegated_did: delegatedDid,
controller_did: controllerDid
})
})
const data = await finishResponse.json()
if (!finishResponse.ok || data.success === false || data.error) {
error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed')
submitting = false
return
}
if (data.needs_totp) {
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = $_('oauthDelegation.unexpectedResponse')
submitting = false
} catch (e) {
console.error('Passkey login error:', e)
error = $_('oauthDelegation.authFailed')
submitting = false
}
}
async function handlePasswordSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri || !controllerDid || !delegatedDid) {
error = $_('oauthDelegation.missingInfo')
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/delegation/auth', {
method: 'POST',
headers: {
@@ -222,30 +84,19 @@
body: JSON.stringify({
request_uri: requestUri,
delegated_did: delegatedDid,
controller_did: controllerDid,
password,
remember_device: rememberDevice
controller_did: resolvedDid,
auth_method: 'cross_pds'
})
})
const data = await response.json()
if (!response.ok || data.success === false || data.error) {
error = data.error_description || data.error || $_('oauthDelegation.authFailed')
error = data.error || $_('oauthDelegation.authFailed')
submitting = false
return
}
if (data.needs_totp) {
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
return
}
if (data.needs_2fa) {
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
@@ -254,7 +105,8 @@
error = $_('oauthDelegation.unexpectedResponse')
submitting = false
} catch {
error = $_('oauthDelegation.authFailed')
error = $_('oauthDelegation.controllerNotFound')
} finally {
submitting = false
}
}
@@ -285,12 +137,6 @@
window.history.back()
}
}
function goBack() {
step = 'identifier'
password = ''
error = null
}
</script>
<div class="delegation-container">
@@ -298,7 +144,7 @@
<div class="loading">
<p>{$_('oauthDelegation.loading')}</p>
</div>
{:else if step === 'identifier'}
{:else}
<header class="page-header">
<h1>{$_('oauthDelegation.title')}</h1>
<p class="subtitle">
@@ -311,7 +157,7 @@
<div class="error">{error}</div>
{/if}
<form onsubmit={handleIdentifierSubmit}>
<form onsubmit={handleSubmit}>
<div class="field">
<label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label>
<input
@@ -334,109 +180,6 @@
</button>
</div>
</form>
{:else if step === 'password'}
<header class="page-header">
<h1>{$_('oauthDelegation.signInAsController')}</h1>
<p class="subtitle">
{$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })}
</p>
</header>
{#if error}
<div class="error">{error}</div>
{/if}
<button class="back-link" onclick={goBack} disabled={submitting}>
&larr; {$_('oauthDelegation.useDifferentController')}
</button>
<form onsubmit={handlePasswordSubmit}>
{#if passkeySupported && hasPasskeys}
<div class="auth-methods">
<div class="passkey-method">
<h3>{$_('oauthDelegation.signInWithPasskey')}</h3>
<button
type="button"
class="passkey-btn"
onclick={handlePasskeyLogin}
disabled={submitting}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
<path d="M17 17v4l3-2-3-2z" />
<path d="M12 11c-4 0-6 2-6 4v4h9" />
</svg>
<span class="passkey-text">
{submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')}
</span>
</button>
</div>
<div class="method-divider">
<span>{$_('oauthDelegation.or')}</span>
</div>
<div class="password-method">
<h3>{$_('oauthDelegation.password')}</h3>
<div class="field">
<input
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauthDelegation.enterPassword')}
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauthDelegation.rememberDevice')}</span>
</label>
<button type="submit" class="submit-btn" disabled={submitting || !password}>
{submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
</button>
</div>
</div>
{:else}
<div class="field">
<label for="password">{$_('oauthDelegation.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauthDelegation.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" class="submit-btn" disabled={submitting || !password}>
{submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
</button>
</div>
{/if}
</form>
{:else}
<header class="page-header">
<h1>{$_('oauthDelegation.title')}</h1>
</header>
<div class="error">{error || $_('oauthDelegation.unableToLoad')}</div>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel}>
{$_('oauthDelegation.goBack')}
</button>
</div>
{/if}
</div>
@@ -469,111 +212,12 @@
line-height: 1.6;
}
.back-link {
display: inline-flex;
align-items: center;
padding: var(--space-2) 0;
background: none;
border: none;
color: var(--accent);
font-size: var(--text-sm);
cursor: pointer;
margin-bottom: var(--space-4);
}
.back-link:hover:not(:disabled) {
text-decoration: underline;
}
.back-link:disabled {
opacity: 0.6;
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.auth-methods {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
margin-top: var(--space-4);
}
@media (min-width: 600px) {
.auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
}
.passkey-method,
.password-method {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
}
.passkey-method h3,
.password-method h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.method-divider {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: var(--text-sm);
}
@media (min-width: 600px) {
.method-divider {
flex-direction: column;
padding: 0 var(--space-3);
}
.method-divider::before,
.method-divider::after {
content: '';
width: 1px;
height: var(--space-6);
background: var(--border-color);
}
.method-divider span {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
padding: var(--space-2) 0;
}
}
@media (max-width: 599px) {
.method-divider {
gap: var(--space-4);
}
.method-divider::before,
.method-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
}
}
.field {
display: flex;
flex-direction: column;
@@ -585,7 +229,6 @@
font-weight: var(--font-medium);
}
input[type="password"],
input[type="text"] {
padding: var(--space-3);
border: 1px solid var(--border-color);
@@ -600,20 +243,6 @@
border-color: var(--accent);
}
.remember-device {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.remember-device input {
width: 16px;
height: 16px;
}
.error {
padding: var(--space-3);
background: var(--error-bg);
@@ -664,40 +293,4 @@
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.passkey-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3);
background: var(--accent);
color: var(--text-inverse);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
font-size: var(--text-base);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.passkey-btn:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.passkey-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.passkey-icon {
width: 20px;
height: 20px;
}
.passkey-text {
flex: 1;
text-align: left;
}
</style>