refactor: update routes, backend verification tweaks, and restyle

This commit is contained in:
Lewis
2026-03-19 14:26:26 +00:00
committed by Tangled
parent 81fc03c705
commit 5c8894d531
36 changed files with 567 additions and 850 deletions
Generated
+20 -20
View File
@@ -6094,7 +6094,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"anyhow",
"axum",
@@ -6142,7 +6142,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"anyhow",
"base32",
@@ -6165,7 +6165,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6179,7 +6179,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"confique",
"serde",
@@ -6202,7 +6202,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -6218,7 +6218,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"chrono",
@@ -6235,7 +6235,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6251,7 +6251,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"bytes",
@@ -6262,7 +6262,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"chrono",
"hickory-resolver",
@@ -6280,7 +6280,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"anyhow",
"axum",
@@ -6303,7 +6303,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"axum",
"base64 0.22.1",
@@ -6336,7 +6336,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6424,7 +6424,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"bytes",
"cid",
@@ -6436,7 +6436,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"backon",
@@ -6461,7 +6461,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"axum",
"futures",
@@ -6477,7 +6477,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"axum",
"clap",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"async-trait",
"aws-config",
@@ -6514,7 +6514,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"anyhow",
"axum",
@@ -6536,7 +6536,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"chrono",
"cid",
+1 -1
View File
@@ -24,7 +24,7 @@ members = [
]
[workspace.package]
version = "0.4.4"
version = "0.4.5"
edition = "2024"
license = "AGPL-3.0-or-later"
+15 -12
View File
@@ -153,9 +153,7 @@ pub async fn create_account(
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = if is_migration {
None
} else {
let verification_recipient = {
Some(match verification_channel {
tranquil_db_traits::CommsChannel::Email => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
@@ -372,9 +370,11 @@ pub async fn create_account(
return ApiError::InternalError(None).into_response();
}
let hostname = &tranquil_config::get().server.hostname;
let verification_required = if let Some(ref user_email) = email {
let verification_required = if let Some(ref recipient) = verification_recipient {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_typed, user_email,
&did_typed,
verification_channel,
recipient,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
@@ -382,13 +382,14 @@ pub async fn create_account(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
reactivated.user_id,
user_email,
verification_channel,
recipient,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
warn!("Failed to enqueue migration verification: {:?}", e);
}
true
} else {
@@ -403,7 +404,7 @@ pub async fn create_account(
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
verification_channel: tranquil_db_traits::CommsChannel::Email,
verification_channel,
}),
)
.into_response();
@@ -668,10 +669,11 @@ pub async fn create_account(
);
}
}
} else if let Some(ref user_email) = email {
} else if let Some(ref recipient) = verification_recipient {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_for_commit,
user_email,
verification_channel,
recipient,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
@@ -679,13 +681,14 @@ pub async fn create_account(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
user_email,
verification_channel,
recipient,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
warn!("Failed to enqueue migration verification: {:?}", e);
}
}
-4
View File
@@ -215,10 +215,6 @@ pub fn api_routes() -> axum::Router<AppState> {
"/_account.checkEmailInUse",
post(server::check_email_in_use),
)
.route(
"/_account.checkCommsChannelInUse",
post(server::check_comms_channel_in_use),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(server::reserve_signing_key),
-34
View File
@@ -606,37 +606,3 @@ pub async fn check_email_in_use(
}))
.into_response()
}
#[derive(Deserialize)]
pub struct CheckCommsChannelInUseInput {
pub channel: CommsChannel,
pub identifier: String,
}
pub async fn check_comms_channel_in_use(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckCommsChannelInUseInput>,
) -> Response {
let identifier = input.identifier.trim();
if identifier.is_empty() {
return ApiError::InvalidRequest("identifier is required".into()).into_response();
}
let count = match state
.user_repo
.count_accounts_by_comms_identifier(input.channel, identifier)
.await
{
Ok(c) => c,
Err(e) => {
error!("DB error checking comms channel usage: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
Json(json!({
"inUse": count > 0,
}))
.into_response()
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub use account_status::{
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{
authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use,
authorize_email_update, check_channel_verified, check_email_in_use,
check_email_update_status, check_email_verified, confirm_email, request_email_update,
update_email,
};
+16 -7
View File
@@ -40,7 +40,8 @@ pub async fn verify_migration_email(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendMigrationVerificationInput {
pub email: String,
pub channel: Option<tranquil_db_traits::CommsChannel>,
pub identifier: String,
}
#[derive(Serialize)]
@@ -53,9 +54,12 @@ pub async fn resend_migration_verification(
State(state): State<AppState>,
Json(input): Json<ResendMigrationVerificationInput>,
) -> Result<Json<ResendMigrationVerificationOutput>, ApiError> {
let email = input.email.trim().to_lowercase();
let channel = input
.channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let identifier = input.identifier.trim().to_lowercase();
let user = match state.user_repo.get_by_email(&email).await {
let user = match state.user_repo.get_by_email(&identifier).await {
Ok(Some(u)) => u,
Ok(None) => {
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
@@ -71,23 +75,28 @@ pub async fn resend_migration_verification(
}
let hostname = &tranquil_config::get().server.hostname;
let token = tranquil_pds::auth::verification_token::generate_migration_token(&user.did, &email);
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&user.did,
channel,
&identifier,
);
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
&email,
channel,
&identifier,
&formatted_token,
hostname,
)
.await
{
warn!(error = ?e, "Failed to enqueue migration verification email");
warn!(error = ?e, channel = ?channel, "Failed to enqueue migration verification");
}
info!(did = %user.did, "Resent migration verification email");
info!(did = %user.did, channel = ?channel, "Resent migration verification");
Ok(Json(ResendMigrationVerificationOutput { sent: true }))
}
+36 -16
View File
@@ -72,10 +72,6 @@ async fn handle_migration_verification(
channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
if channel != CommsChannel::Email {
return Err(ApiError::InvalidChannel);
}
let user = state
.user_repo
.get_verification_info(did)
@@ -83,19 +79,43 @@ async fn handle_migration_verification(
.log_db_err("during migration verification")?
.ok_or(ApiError::AccountNotFound)?;
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err(ApiError::IdentifierMismatch);
}
match channel {
CommsChannel::Email => {
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err(ApiError::IdentifierMismatch);
}
if !user.channel_verification.email {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email_verified status")?;
}
}
CommsChannel::Discord => {
state
.user_repo
.set_discord_verified_flag(user.id)
.await
.log_db_err("updating discord verified status")?;
}
CommsChannel::Telegram => {
state
.user_repo
.set_telegram_verified_flag(user.id)
.await
.log_db_err("updating telegram verified status")?;
}
CommsChannel::Signal => {
state
.user_repo
.set_signal_verified_flag(user.id)
.await
.log_db_err("updating signal verified status")?;
}
};
if !user.channel_verification.email {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email_verified status")?;
}
info!(did = %did, "Migration email verified successfully");
info!(did = %did, channel = ?channel, "Migration verification completed successfully");
Ok(Json(VerifyTokenOutput {
success: true,
-6
View File
@@ -429,12 +429,6 @@ pub trait UserRepository: Send + Sync {
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>;
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError>;
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError>;
async fn set_password_reset_code(
-27
View File
@@ -1654,33 +1654,6 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)
}
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError> {
let query = match channel {
CommsChannel::Email => {
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Discord => {
"SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Telegram => {
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Signal => {
"SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL"
}
};
sqlx::query_scalar(query)
.bind(identifier)
.fetch_one(&self.pool)
.await
.map(|c: Option<i64>| c.unwrap_or(0))
.map_err(map_sqlx_error)
}
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
sqlx::query_scalar!(
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
@@ -323,9 +323,16 @@ pub async fn authorize_get(
.await
&& !accounts.is_empty()
{
let login_hint_param = request_data
.parameters
.login_hint
.as_ref()
.map(|h| format!("&login_hint={}", url_encode(h)))
.unwrap_or_default();
return redirect_see_other(&format!(
"/app/oauth/accounts?request_uri={}",
url_encode(&request_uri)
"/app/oauth/accounts?request_uri={}{}",
url_encode(&request_uri),
login_hint_param
));
}
redirect_see_other(&format!(
@@ -80,13 +80,8 @@ pub fn generate_signup_token(did: &Did, channel: CommsChannel, identifier: &str)
generate_token(did, VerificationPurpose::Signup, channel, identifier)
}
pub fn generate_migration_token(did: &Did, email: &str) -> String {
generate_token(
did,
VerificationPurpose::Migration,
CommsChannel::Email,
email,
)
pub fn generate_migration_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
generate_token(did, VerificationPurpose::Migration, channel, identifier)
}
pub fn generate_channel_update_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
@@ -196,16 +191,17 @@ pub fn verify_signup_token(
pub fn verify_migration_token(
token: &str,
expected_email: &str,
expected_channel: CommsChannel,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.purpose != VerificationPurpose::Migration {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != CommsChannel::Email {
if parsed.channel != expected_channel {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_email);
let expected_hash = hash_identifier(expected_identifier);
if parsed.identifier_hash != expected_hash {
return Err(VerifyError::IdentifierMismatch);
}
@@ -345,8 +341,8 @@ mod tests {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let token = generate_migration_token(&did, email);
let result = verify_migration_token(&token, email);
let token = generate_migration_token(&did, CommsChannel::Email, email);
let result = verify_migration_token(&token, CommsChannel::Email, email);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
assert_eq!(parsed.did, did);
@@ -409,7 +405,7 @@ mod tests {
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
let result = verify_migration_token(&signup_token, email);
let result = verify_migration_token(&signup_token, CommsChannel::Email, email);
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
}
+6 -5
View File
@@ -499,7 +499,8 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
email: &str,
channel: tranquil_db_traits::CommsChannel,
recipient: &str,
token: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
@@ -508,12 +509,12 @@ pub mod repo {
.await?
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let encoded_email = urlencoding::encode(email);
let encoded_recipient = urlencoding::encode(recipient);
let encoded_token = urlencoding::encode(token);
let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
hostname, encoded_token, encoded_recipient
);
let body = format_message(
strings.migration_verification_body,
@@ -531,9 +532,9 @@ pub mod repo {
infra_repo
.enqueue_comms(
Some(user_id),
tranquil_db_traits::CommsChannel::Email,
channel,
CommsType::MigrationVerification,
email,
recipient,
Some(&subject),
&body,
None,
+1 -1
View File
@@ -127,7 +127,7 @@ async fn test_legacy_2fa_auth_factor_required() {
body["message"]
.as_str()
.unwrap_or("")
.contains("sign in code")
.contains("sign-in code")
);
}
+4 -10
View File
@@ -15,17 +15,15 @@
import OAuthConsent from './routes/OAuthConsent.svelte'
import OAuthLogin from './routes/OAuthLogin.svelte'
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthTotp from './routes/OAuthTotp.svelte'
import OAuthVerifyCode from './routes/OAuthVerifyCode.svelte'
import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthDelegation from './routes/OAuthDelegation.svelte'
import OAuthError from './routes/OAuthError.svelte'
import SsoRegisterComplete from './routes/SsoRegisterComplete.svelte'
import Register from './routes/Register.svelte'
import RegisterPassword from './routes/RegisterPassword.svelte'
import ActAs from './routes/ActAs.svelte'
import Migration from './routes/Migration.svelte'
import UiTest from './routes/UiTest.svelte'
import { _ } from './lib/i18n'
initI18n()
@@ -105,9 +103,8 @@
case '/oauth/accounts':
return OAuthAccounts
case '/oauth/2fa':
return OAuth2FA
case '/oauth/totp':
return OAuthTotp
return OAuthVerifyCode
case '/oauth/passkey':
return OAuthPasskey
case '/oauth/delegation':
@@ -118,17 +115,14 @@
return SsoRegisterComplete
case '/register':
case '/oauth/register':
case '/oauth/register-password':
return Register
case '/oauth/register-sso':
return RegisterSso
case '/oauth/register-password':
return RegisterPassword
case '/act-as':
return ActAs
case '/migrate':
return Migration
case '/ui-test':
return UiTest
default:
return Login
}
@@ -10,15 +10,11 @@
signalUsername: string
availableChannels: VerificationChannel[]
disabled?: boolean
discordInUse?: boolean
telegramInUse?: boolean
signalInUse?: boolean
onChannelChange: (channel: VerificationChannel) => void
onEmailChange: (value: string) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void
}
let {
@@ -29,15 +25,11 @@
signalUsername,
availableChannels,
disabled = false,
discordInUse = false,
telegramInUse = false,
signalInUse = false,
onChannelChange,
onEmailChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
onCheckInUse,
}: Props = $props()
function channelLabel(ch: string): string {
@@ -92,14 +84,10 @@
type="text"
value={discordUsername}
oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('discord', discordUsername)}
placeholder={$_('register.discordUsernamePlaceholder')}
{disabled}
required
/>
{#if discordInUse}
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
{/if}
</div>
{:else if channel === 'telegram'}
<div>
@@ -109,14 +97,10 @@
type="text"
value={telegramUsername}
oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('telegram', telegramUsername)}
placeholder={$_('register.telegramUsernamePlaceholder')}
{disabled}
required
/>
{#if telegramInUse}
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
{/if}
</div>
{:else if channel === 'signal'}
<div>
@@ -126,14 +110,10 @@
type="tel"
value={signalUsername}
oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('signal', signalUsername)}
placeholder={$_('register.signalUsernamePlaceholder')}
{disabled}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
{#if signalInUse}
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
{/if}
</div>
{/if}
@@ -33,9 +33,6 @@
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
@@ -147,23 +144,6 @@
return formatDateTime(dateStr)
}
async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) {
const trimmed = identifier.trim()
if (!trimmed) {
const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false }
resetMap[channel]()
return
}
try {
const result = await api.checkCommsChannelInUse(channel, trimmed)
const setMap = { discord: (v: boolean) => discordInUse = v, telegram: (v: boolean) => telegramInUse = v, signal: (v: boolean) => signalInUse = v }
setMap[channel](result.inUse)
} catch {
const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false }
resetMap[channel]()
}
}
const channels = ['email', 'discord', 'telegram', 'signal']
function getChannelName(id: string): string {
@@ -261,14 +241,10 @@
id="discord"
type="text"
bind:value={discordUsername}
onblur={() => checkChannelInUse('discord', discordUsername)}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if discordInUse}
<p class="hint warning">{$_('comms.discordInUseWarning')}</p>
{/if}
{#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="discord-verify-prompt">
@@ -296,14 +272,10 @@
id="telegram"
type="text"
bind:value={telegramUsername}
onblur={() => checkChannelInUse('telegram', telegramUsername)}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if telegramInUse}
<p class="hint warning">{$_('comms.telegramInUseWarning')}</p>
{/if}
{#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="telegram-verify-prompt">
@@ -329,14 +301,10 @@
id="signal"
type="text"
bind:value={signalUsername}
onblur={() => checkChannelInUse('signal', signalUsername)}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if signalInUse}
<p class="hint warning">{$_('comms.signalInUseWarning')}</p>
{/if}
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
-9
View File
@@ -525,15 +525,6 @@ export const api = {
});
},
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 });
+8 -9
View File
@@ -1,4 +1,4 @@
import { api, ApiError, castSession, typedApi } from "./api.ts";
import { api, ApiError, castSession } from "./api.ts";
import type {
CreateAccountParams,
CreateAccountResult,
@@ -15,7 +15,6 @@ import {
unsafeAsRefreshToken,
} from "./types/branded.ts";
import { err, isErr, isOk, ok, type Result } from "./types/result.ts";
import { assertNever } from "./types/exhaustive.ts";
import {
checkForOAuthCallback,
clearAllOAuthState,
@@ -392,15 +391,15 @@ export async function login(
: null;
setLoading(previousSession);
const result = await typedApi.createSession(identifier, password);
if (isErr(result)) {
const error = toAuthError(result.error);
try {
const session = await api.createSession(identifier, password);
setAuthenticated(session);
return ok(session);
} catch (e) {
const error = toAuthError(e);
setError(error);
return err(error);
}
setAuthenticated(result.value);
return ok(result.value);
}
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
@@ -654,7 +653,7 @@ export function matchAuthState<T>(handlers: {
case "error":
return handlers.error(current.error, current.savedAccounts);
default:
return assertNever(current);
throw new Error(`Unexpected auth state: ${(current as { kind: string }).kind}`);
}
}
@@ -38,9 +38,6 @@ export interface RegistrationFlowState {
selectedDomain: string;
handleAvailable: boolean | null;
checkingHandle: boolean;
discordInUse: boolean;
telegramInUse: boolean;
signalInUse: boolean;
}
export function createRegistrationFlow(
@@ -73,9 +70,6 @@ export function createRegistrationFlow(
selectedDomain: "",
handleAvailable: null,
checkingHandle: false,
discordInUse: false,
telegramInUse: false,
signalInUse: false,
});
function getPdsEndpoint(): string {
@@ -152,23 +146,6 @@ export function createRegistrationFlow(
}
}
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") {
@@ -498,7 +475,6 @@ export function createRegistrationFlow(
finalizeSession,
goBack,
checkHandleAvailability,
checkCommsChannelInUse,
setError(msg: string) {
state.error = msg;
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord Username",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "Discord username in use by another account",
"telegram": "Telegram",
"telegramUsername": "Telegram Username",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "Telegram username in use by another account",
"signal": "Signal",
"signalUsername": "Signal Username",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "Signal username in use by another account",
"notConfigured": "not configured",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter your invite code",
@@ -412,9 +409,6 @@
"verifiedSuccess": "{channel} verified successfully",
"messageHistory": "Message History",
"noMessages": "No messages found.",
"discordInUseWarning": "This Discord username is already associated with another account.",
"telegramInUseWarning": "This Telegram username is already associated with another account.",
"signalInUseWarning": "This Signal username is already associated with another account.",
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
"telegramOpenLink": "Open Telegram to verify",
"discordStartBot": "DM @{botUsername} on Discord and send /start {handle}",
@@ -1027,12 +1021,17 @@
"continue": "Continue"
},
"emailVerify": {
"title": "Verify Your Email",
"title": "Verify Your Account",
"desc": "A verification code has been sent to {email}.",
"hint": "Enter the code below, or click the link in the email to continue automatically.",
"hint": "Enter the code below, or click the link in the message to continue automatically.",
"tokenLabel": "Verification Code",
"tokenPlaceholder": "Enter code from email",
"resend": "Resend Code"
"tokenPlaceholder": "Enter verification code",
"resend": "Resend Code",
"telegramInstructions": "Message the Telegram bot to verify your account.",
"discordInstructions": "Message the Discord bot to verify your account.",
"openTelegram": "Open Telegram to verify",
"openDiscord": "Open Discord to verify",
"waitingForVerification": "Waiting for verification..."
},
"plcToken": {
"title": "Verify Migration",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord-käyttäjänimi",
"discordUsernamePlaceholder": "käyttäjänimesi",
"discordInUseWarning": "Tämä Discord-käyttäjänimi 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",
"signalUsername": "Signal-käyttäjänimi",
"signalUsernamePlaceholder": "käyttäjänimi.01",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"notConfigured": "ei määritetty",
"inviteCode": "Kutsukoodi",
"inviteCodePlaceholder": "Syötä kutsukoodisi",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} vahvistettu",
"messageHistory": "Viestihistoria",
"noMessages": "Viestejä ei löytynyt.",
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
"discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa",
@@ -1026,12 +1020,17 @@
"continue": "Jatka"
},
"emailVerify": {
"title": "Vahvista sähköpostisi",
"title": "Vahvista tilisi",
"desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.",
"hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.",
"hint": "Syötä koodi alle tai klikkaa viestissä olevaa linkkiä jatkaaksesi automaattisesti.",
"tokenLabel": "Vahvistuskoodi",
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
"resend": "Lähetä koodi uudelleen"
"tokenPlaceholder": "Syötä vahvistuskoodi",
"resend": "Lähetä koodi uudelleen",
"telegramInstructions": "Lähetä viesti Telegram-botille vahvistaaksesi tilisi.",
"discordInstructions": "Lähetä viesti Discord-botille vahvistaaksesi tilisi.",
"openTelegram": "Avaa Telegram vahvistaaksesi",
"openDiscord": "Avaa Discord vahvistaaksesi",
"waitingForVerification": "Odotetaan vahvistusta..."
},
"plcToken": {
"title": "Vahvista siirto",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord ユーザー名",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegram": "Telegram",
"telegramUsername": "Telegram ユーザー名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signal": "Signal",
"signalUsername": "Signal ユーザー名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"notConfigured": "未設定",
"inviteCode": "招待コード",
"inviteCodePlaceholder": "招待コードを入力",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} を確認しました",
"messageHistory": "メッセージ履歴",
"noMessages": "メッセージが見つかりません。",
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
"telegramOpenLink": "Telegram で確認する",
"discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信",
@@ -1026,12 +1020,17 @@
"continue": "続ける"
},
"emailVerify": {
"title": "メールアドレスを確認",
"title": "アカウントを確認",
"desc": "確認コードが {email} に送信されました。",
"hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。",
"hint": "下記にコードを入力するか、メッセージ内のリンクをクリックして自動的に続行できます。",
"tokenLabel": "確認コード",
"tokenPlaceholder": "メールに記載されたコードを入力",
"resend": "コードを再送信"
"tokenPlaceholder": "確認コードを入力",
"resend": "コードを再送信",
"telegramInstructions": "Telegram ボットにメッセージを送信してアカウントを確認してください。",
"discordInstructions": "Discord ボットにメッセージを送信してアカウントを確認してください。",
"openTelegram": "Telegram で確認する",
"openDiscord": "Discord で確認する",
"waitingForVerification": "確認を待っています..."
},
"plcToken": {
"title": "移行を確認",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord 사용자명",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegram": "Telegram",
"telegramUsername": "Telegram 사용자 이름",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signal": "Signal",
"signalUsername": "Signal 사용자명",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"notConfigured": "구성되지 않음",
"inviteCode": "초대 코드",
"inviteCodePlaceholder": "초대 코드 입력",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} 인증 완료",
"messageHistory": "메시지 기록",
"noMessages": "메시지가 없습니다.",
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
"telegramOpenLink": "Telegram에서 인증하기",
"discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기",
@@ -1026,12 +1020,17 @@
"continue": "계속"
},
"emailVerify": {
"title": "이메일 인증",
"title": "계정 인증",
"desc": "인증 코드가 {email}(으)로 전송되었습니다.",
"hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
"hint": "아래에 코드를 입력하거나, 메시지의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
"tokenLabel": "인증 코드",
"tokenPlaceholder": "이메일에서 받은 코드 입력",
"resend": "코드 재전송"
"tokenPlaceholder": "인증 코드 입력",
"resend": "코드 재전송",
"telegramInstructions": "Telegram 봇에 메시지를 보내 계정을 인증하세요.",
"discordInstructions": "Discord 봇에 메시지를 보내 계정을 인증하세요.",
"openTelegram": "Telegram에서 인증하기",
"openDiscord": "Discord에서 인증하기",
"waitingForVerification": "인증 대기 중..."
},
"plcToken": {
"title": "마이그레이션 확인",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord-användarnamn",
"discordUsernamePlaceholder": "dittanvändarnamn",
"discordInUseWarning": "Detta Discord-användarnamn ä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",
"signalUsername": "Signal-användarnamn",
"signalUsernamePlaceholder": "användarnamn.01",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"notConfigured": "ej konfigurerad",
"inviteCode": "Inbjudningskod",
"inviteCodePlaceholder": "Ange din inbjudningskod",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} verifierad",
"messageHistory": "Meddelandehistorik",
"noMessages": "Inga meddelanden hittades.",
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
"telegramOpenLink": "Öppna Telegram för att verifiera",
"discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}",
@@ -1026,12 +1020,17 @@
"continue": "Fortsätt"
},
"emailVerify": {
"title": "Verifiera din e-post",
"title": "Verifiera ditt konto",
"desc": "En verifieringskod har skickats till {email}.",
"hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.",
"hint": "Ange koden nedan eller klicka på länken i meddelandet för att fortsätta automatiskt.",
"tokenLabel": "Verifieringskod",
"tokenPlaceholder": "Ange kod från e-post",
"resend": "Skicka kod igen"
"tokenPlaceholder": "Ange verifieringskod",
"resend": "Skicka kod igen",
"telegramInstructions": "Skicka ett meddelande till Telegram-boten för att verifiera ditt konto.",
"discordInstructions": "Skicka ett meddelande till Discord-boten för att verifiera ditt konto.",
"openTelegram": "Öppna Telegram för att verifiera",
"openDiscord": "Öppna Discord för att verifiera",
"waitingForVerification": "Väntar på verifiering..."
},
"plcToken": {
"title": "Verifiera flytt",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord 用户名",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
"telegram": "Telegram",
"telegramUsername": "Telegram 用户名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signal": "Signal",
"signalUsername": "Signal 用户名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "此 Signal 用户名已被其他账户使用。",
"notConfigured": "未配置",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入您的邀请码",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} 验证成功",
"messageHistory": "消息历史",
"noMessages": "暂无消息记录",
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signalInUseWarning": "此 Signal 用户名已与另一个账户关联。",
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
"telegramOpenLink": "打开 Telegram 验证",
"discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}",
@@ -1026,12 +1020,17 @@
"continue": "继续"
},
"emailVerify": {
"title": "验证您的邮箱",
"title": "验证您的账户",
"desc": "验证码已发送至 {email}。",
"hint": "在下方输入验证码,或点击邮件中的链接自动继续。",
"hint": "在下方输入验证码,或点击消息中的链接自动继续。",
"tokenLabel": "验证码",
"tokenPlaceholder": "输入邮件中的验证码",
"resend": "重新发送"
"tokenPlaceholder": "输入验证码",
"resend": "重新发送",
"telegramInstructions": "向 Telegram 机器人发送消息以验证您的账户。",
"discordInstructions": "向 Discord 机器人发送消息以验证您的账户。",
"openTelegram": "打开 Telegram 验证",
"openDiscord": "打开 Discord 验证",
"waitingForVerification": "等待验证..."
},
"plcToken": {
"title": "验证迁移",
+18 -6
View File
@@ -13,13 +13,12 @@
let submitting = $state(false)
let accounts = $state<AccountInfo[]>([])
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
function getParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name)
}
async function fetchAccounts() {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (!requestUri) {
error = 'Missing request_uri parameter'
loading = false
@@ -36,6 +35,19 @@
}
const data = await response.json()
accounts = data.accounts || []
const loginHint = getParam('login_hint')
if (loginHint && accounts.length > 0) {
const hint = loginHint.toLowerCase()
const matched = accounts.find(
(a) => a.did === hint || a.handle.toLowerCase() === hint
)
if (matched) {
loading = false
handleSelectAccount(matched.did)
return
}
}
} catch {
error = 'Failed to connect to server'
} finally {
@@ -44,7 +56,7 @@
}
async function handleSelectAccount(did: string) {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (!requestUri) {
error = 'Missing request_uri parameter'
return
@@ -98,7 +110,7 @@
}
function handleDifferentAccount() {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
+8 -8
View File
@@ -310,15 +310,15 @@
<div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div>
<div class="delegation-info">
<div class="info-row">
<span class="label">{$_('oauthConsent.actingAs')}</span>
<span class="did">{consentData.did}</span>
<span class="consent-account-label">{$_('oauthConsent.actingAs')}</span>
<span class="consent-account-did">{consentData.did}</span>
</div>
<div class="info-row">
<span class="label">{$_('oauthConsent.controller')}</span>
<span class="handle">@{consentData.controller_handle || consentData.controller_did}</span>
<span class="consent-account-label">{$_('oauthConsent.controller')}</span>
<span class="consent-account-handle">@{consentData.controller_handle || consentData.controller_did}</span>
</div>
<div class="info-row">
<span class="label">{$_('oauthConsent.accessLevel')}</span>
<span class="consent-account-label">{$_('oauthConsent.accessLevel')}</span>
<span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span>
</div>
</div>
@@ -340,11 +340,11 @@
</div>
{/if}
{:else}
<span class="label">{$_('oauth.consent.signingInAs')}</span>
<span class="consent-account-label">{$_('oauth.consent.signingInAs')}</span>
{#if consentData.handle}
<span class="handle">@{consentData.handle}</span>
<span class="consent-account-handle">@{consentData.handle}</span>
{/if}
<span class="did">{consentData.did}</span>
<span class="consent-account-did">{consentData.did}</span>
{/if}
</div>
</div>
+11 -15
View File
@@ -494,18 +494,17 @@
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
</div>
{/if}
</div>
<div class="cancel-row">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
</div>
{:else}
{#if hasPassword || !securityStatusChecked}
<div>
@@ -526,17 +525,14 @@
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="cancel-row">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
</div>
{/if}
</form>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { getCurrentPath } from '../lib/router.svelte'
let mode = $derived(getCurrentPath().includes('totp') ? 'totp' as const : '2fa' as const)
let code = $state('')
let trustDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
function getChannel(): string {
const params = new URLSearchParams(window.location.search)
return params.get('channel') || 'email'
}
let isBackupCode = $derived(mode === 'totp' && code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
let isTotpCode = $derived(mode === 'totp' && code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
let is2faCode = $derived(mode === '2fa' && code.trim().length === 6)
let canSubmit = $derived(isBackupCode || isTotpCode || is2faCode)
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = mode === 'totp' ? $_('common.error') : $_('oauth.twoFactorCode.errors.missingRequestUri')
return
}
submitting = true
error = null
try {
const body: Record<string, unknown> = {
request_uri: requestUri,
code: mode === 'totp' ? code.trim().toUpperCase() : code.trim(),
}
if (mode === 'totp') {
body.trust_device = trustDevice
}
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(body)
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || $_('common.error')
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.unexpectedResponse') : $_('common.error')
submitting = false
} catch {
error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.connectionFailed') : $_('common.error')
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
}
let channel = $derived(getChannel())
</script>
<div class={mode === 'totp' ? 'oauth-totp-container' : 'oauth-2fa-container'}>
<h1>{mode === 'totp' ? $_('oauth.totp.title') : $_('oauth.twoFactorCode.title')}</h1>
{#if mode === '2fa'}
<p class="subtitle">
{$_('oauth.twoFactorCode.subtitle', { values: { channel } })}
</p>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div>
<label for="code">
{mode === 'totp' ? $_('oauth.totp.codePlaceholder') : $_('oauth.twoFactorCode.codeLabel')}
</label>
{#if mode === 'totp'}
<input
id="code"
type="text"
bind:value={code}
placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')}
disabled={submitting}
required
maxlength="8"
autocomplete="one-time-code"
autocapitalize="characters"
/>
{#if isBackupCode || isTotpCode}
<p class="hint">
{isBackupCode ? $_('oauth.totp.hintBackupCode') : $_('oauth.totp.hintTotpCode')}
</p>
{/if}
{:else}
<input
id="code"
type="text"
bind:value={code}
placeholder={$_('oauth.twoFactorCode.codePlaceholder')}
disabled={submitting}
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
/>
{/if}
</div>
{#if mode === 'totp'}
<label class="trust-device-label">
<input
type="checkbox"
bind:checked={trustDevice}
disabled={submitting}
/>
<span>{$_('oauth.totp.trustDevice')}</span>
</label>
{/if}
<div class="actions">
<button type="button" class="cancel" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !canSubmit}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
+112 -200
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { navigate, routes, getFullUrl, getCurrentPath } from '../lib/router.svelte'
import { api } from '../lib/api'
import { _ } from '../lib/i18n'
import {
@@ -8,21 +8,24 @@
VerificationStep,
KeyChoiceStep,
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import type { RegistrationMode } from '../lib/registration'
import AppPasswordStep from '../components/migration/AppPasswordStep.svelte'
import PasskeySetupStep from '../components/migration/PasskeySetupStep.svelte'
import { performPasskeyRegistration, PasskeyCancelledError } from '../lib/flows/perform-passkey-registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
import IdentityTypeSection from '../components/IdentityTypeSection.svelte'
import CommsChannelPicker from '../components/CommsChannelPicker.svelte'
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
const mode: RegistrationMode = getCurrentPath().includes('register-password') ? 'password' : 'passkey'
const isPasskey = mode === 'passkey'
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
availableCommsChannels?: import('../lib/types/api').VerificationChannel[]
selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
@@ -31,6 +34,7 @@
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
let confirmPassword = $state('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
@@ -99,20 +103,24 @@
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasskeyAccount()
if (isPasskey) {
flow.createPasskeyAccount()
} else {
flow.createPasswordAccount()
}
}
})
async function loadServerInfo() {
try {
const restored = restoreRegistrationFlow()
if (restored && restored.state.mode === 'passkey') {
if (restored && restored.state.mode === mode) {
flow = restored
serverInfo = await api.describeServer()
} else {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
flow = createRegistrationFlow(mode, hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
@@ -128,6 +136,11 @@
const info = flow.info
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
if (!isPasskey) {
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 $_('registerPasskey.errors.inviteRequired')
}
@@ -162,7 +175,7 @@
return
}
if (!window.PublicKeyCredential) {
if (isPasskey && !window.PublicKeyCredential) {
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
return
}
@@ -174,39 +187,25 @@
async function handlePasskeyRegistration() {
if (!flow || !flow.account) return
const { did, setupToken } = flow.account
if (!setupToken) 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
)
const result = await performPasskeyRegistration({
startRegistration: () => api.startPasskeyRegistrationForSetup(
did, setupToken, passkeyName || undefined,
),
completeSetup: (credential, name) => api.completePasskeySetup(
did, setupToken, credential, name,
),
}, passkeyName || undefined)
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
} else if (err instanceof Error) {
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
@@ -221,6 +220,9 @@
async function completeOAuthRegistration() {
const requestUri = getRequestUriFromUrl()
if (!requestUri || !flow?.account) {
if (!isPasskey && flow) {
await flow.finalizeSession()
}
navigate(routes.dashboard)
return
}
@@ -235,7 +237,7 @@
body: JSON.stringify({
request_uri: requestUri,
did: flow.account.did,
app_password: flow.account.appPassword,
app_password: flow.account.appPassword || (isPasskey ? undefined : flow.info.password),
}),
})
@@ -258,26 +260,6 @@
}
}
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()
@@ -332,7 +314,7 @@
<div class="loading"></div>
{:else if flow}
<header class="page-header">
<h1>{$_('oauth.register.title')}</h1>
<h1>{isPasskey ? $_('oauth.register.title') : $_('register.title')}</h1>
{#if clientName}
<p class="subtitle">{$_('oauth.register.subtitle')} <strong>{clientName}</strong></p>
{/if}
@@ -354,7 +336,7 @@
</div>
</div>
<AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
<AccountTypeSwitcher active={mode} {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
<form class="register-form" onsubmit={handleInfoSubmit}>
<div>
@@ -381,124 +363,57 @@
{/if}
</div>
<div>
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{channelLabel('email')}</option>
{#if isChannelAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isChannelAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isChannelAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if !isPasskey}
<div>
<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>
{#if flow.info.verificationChannel === 'email'}
<div>
<label for="email">{$_('register.emailAddress')}</label>
<label for="confirm-password">{$_('register.confirmPassword')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder={$_('register.confirmPasswordPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div>
<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>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
<fieldset class="identity-section">
<legend>{$_('registerPasskey.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>{$_('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>
</fieldset>
<CommsChannelPicker
channel={flow.info.verificationChannel}
email={flow.info.email}
discordUsername={flow.info.discordUsername ?? ''}
telegramUsername={flow.info.telegramUsername ?? ''}
signalUsername={flow.info.signalUsername ?? ''}
availableChannels={serverInfo?.availableCommsChannels ?? ['email']}
disabled={flow.state.submitting}
onChannelChange={(ch) => { if (flow) flow.info.verificationChannel = ch }}
onEmailChange={(v) => { if (flow) flow.info.email = v }}
onDiscordChange={(v) => { if (flow) flow.info.discordUsername = v }}
onTelegramChange={(v) => { if (flow) flow.info.telegramUsername = v }}
onSignalChange={(v) => { if (flow) flow.info.signalUsername = v }}
/>
{#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>
{#if $_('registerPasskey.didWebWarning3')}
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
{/if}
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div>
<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 ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
<IdentityTypeSection
didType={flow.info.didType}
externalDid={flow.info.externalDid ?? ''}
disabled={flow.state.submitting}
selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false}
defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}
onDidTypeChange={(v) => { if (flow) flow.info.didType = v }}
onExternalDidChange={(v) => { if (flow) flow.info.externalDid = v }}
/>
{#if serverInfo?.inviteCodeRequired}
<div>
@@ -528,42 +443,34 @@
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
<DidDocStep
{flow}
type="initial"
onConfirm={() => isPasskey ? flow?.createPasskeyAccount() : flow?.createPasswordAccount()}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
<div class="loading">
<p>{$_('registerPasskey.creatingAccount')}</p>
<p>{isPasskey ? $_('registerPasskey.creatingAccount') : $_('common.creating')}</p>
</div>
{:else if flow.state.step === 'passkey'}
<div class="passkey-step">
<h2>{$_('registerPasskey.setupPasskey')}</h2>
<p>{$_('registerPasskey.passkeyDescription')}</p>
{:else if isPasskey && flow.state.step === 'passkey'}
<PasskeySetupStep
{passkeyName}
loading={flow.state.submitting}
error={flow.state.error}
onPasskeyNameChange={(n) => passkeyName = n}
onRegister={handlePasskeyRegistration}
/>
<div class="field">
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
<input
id="passkey-name"
type="text"
bind:value={passkeyName}
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
disabled={flow.state.submitting}
/>
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<button
type="button"
class="primary"
onclick={handlePasskeyRegistration}
disabled={flow.state.submitting}
>
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.createPasskey')}
</button>
</div>
{:else if flow.state.step === 'app-password'}
<AppPasswordStep {flow} />
{:else if isPasskey && flow.state.step === 'app-password'}
<AppPasswordStep
appPassword={flow.account?.appPassword ?? ''}
appPasswordName={flow.account?.appPasswordName ?? ''}
loading={flow.state.submitting}
onContinue={() => flow!.proceedFromAppPassword()}
/>
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
@@ -571,10 +478,15 @@
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
{:else if flow.state.step === 'activating'}
{:else if isPasskey && flow.state.step === 'activating'}
<div class="loading">
<p>{$_('registerPasskey.activatingAccount')}</p>
</div>
{:else if !isPasskey && flow.state.step === 'redirect-to-dashboard'}
<div class="loading">
<p>{$_('register.redirecting')}</p>
</div>
{/if}
{/if}
</div>
-16
View File
@@ -1,19 +1,9 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { getAuthState } from '../lib/auth.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { unsafeAsEmail } from '../lib/types/branded'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
const session = $derived(getSession())
let email = $state('')
let token = $state('')
let newPassword = $state('')
@@ -23,12 +13,6 @@
let success = $state<string | null>(null)
let tokenSent = $state(false)
$effect(() => {
if (session) {
navigate(routes.dashboard)
}
})
async function handleRequestReset(e: Event) {
e.preventDefault()
if (!email) return
+11 -52
View File
@@ -4,6 +4,7 @@
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
import IdentityTypeSection from '../components/IdentityTypeSection.svelte'
interface PendingRegistration {
request_uri: string
@@ -91,9 +92,7 @@
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 ''
@@ -497,55 +496,15 @@
</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>
<IdentityTypeSection
{didType}
{externalDid}
disabled={submitting}
selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false}
defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}
onDidTypeChange={(v) => didType = v}
onExternalDidChange={(v) => externalDid = v}
/>
{#if serverInfo?.inviteCodeRequired}
<div>
+1 -1
View File
@@ -261,7 +261,7 @@
error = null
try {
await api.resendMigrationVerification(unsafeAsEmail(identifier.trim()))
await api.resendMigrationVerification('email', identifier.trim())
resendMessage = $_('verify.codeResentDetail')
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to resend verification'
+3 -94
View File
@@ -435,6 +435,8 @@ section h3 {
.modal-backdrop {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: var(--overlay-bg);
z-index: var(--z-modal);
display: flex;
@@ -496,12 +498,6 @@ section h3 {
padding: var(--space-7);
}
.page-lg {
max-width: var(--width-xl);
margin: 0 auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
@@ -541,22 +537,6 @@ section h3 {
text-decoration: none;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-sm {
font-size: var(--text-sm);
}
.text-xs {
font-size: var(--text-xs);
}
.text-center {
text-align: center;
}
@@ -565,25 +545,6 @@ section h3 {
font-family: var(--font-mono);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-5 {
margin-top: var(--space-5);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-5 {
margin-bottom: var(--space-5);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
@@ -607,12 +568,6 @@ section h3 {
min-width: 0;
}
.form-row {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
@media (min-width: 600px) {
.form-row {
grid-template-columns: repeat(2, 1fr);
@@ -650,7 +605,6 @@ section h3 {
margin-bottom: 0;
}
.skeleton {
background: var(--bg-secondary);
}
@@ -843,10 +797,6 @@ section h3 {
min-width: 0;
}
.form-links {
margin-top: var(--space-6);
}
.form-links .link-text {
text-align: center;
color: var(--text-secondary);
@@ -989,25 +939,11 @@ a.btn:hover {
text-decoration: none;
}
.card-interactive {
cursor: pointer;
}
.card-interactive:hover {
border-color: var(--secondary);
box-shadow: 0 2px 8px var(--accent-muted);
}
.card-danger {
background: var(--error-bg);
border-color: var(--error-border);
}
.padding-none { padding: 0; }
.padding-sm { padding: var(--space-4); }
.padding-md { padding: var(--space-6); }
.padding-lg { padding: var(--space-7); }
section.danger {
background: var(--error-bg);
}
@@ -1082,26 +1018,6 @@ section .description {
pointer-events: auto;
}
.toast-success {
background: var(--success-bg);
color: var(--success-text);
}
.toast-error {
background: var(--error-bg);
color: var(--error-text);
}
.toast-warning {
background: var(--warning-bg);
color: var(--warning-text);
}
.toast-info {
background: var(--bg-secondary);
color: var(--text-primary);
}
.toast-message {
flex: 1;
font-size: var(--text-sm);
@@ -1286,7 +1202,7 @@ section .description {
margin-bottom: var(--space-4);
}
.modal-content button:not(.tab) {
.modal-content button:not(.tab):not(.secondary) {
width: 100%;
}
@@ -1338,7 +1254,6 @@ svg.sso-icon {
display: block;
}
.form-actions {
display: flex;
flex-direction: row;
@@ -1346,12 +1261,6 @@ svg.sso-icon {
margin-top: var(--space-5);
}
.cancel-row {
display: flex;
justify-content: center;
margin-top: var(--space-4);
}
.form-actions .primary {
flex: 1;
}
+62 -145
View File
@@ -1,19 +1,3 @@
.register-redirect {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.loading-content p {
margin: 0;
color: var(--text-secondary);
@@ -85,16 +69,6 @@
margin-top: var(--space-3);
}
@media (min-width: 600px) {
.login-page .actions {
flex-direction: row;
}
.login-page .actions button {
flex: 1;
}
}
.link-text {
margin-top: var(--space-6);
font-size: var(--text-sm);
@@ -543,13 +517,6 @@ button.forget-btn:hover {
padding: 0 var(--space-2);
}
.passkey-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 500px;
}
.passkey-step h2 {
margin: 0;
}
@@ -630,13 +597,6 @@ button.forget-btn:hover {
margin-top: var(--space-4);
}
@media (min-width: 600px) {
.oauth-login .auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
}
.auth-methods {
display: grid;
grid-template-columns: 1fr;
@@ -644,25 +604,10 @@ button.forget-btn:hover {
margin-top: var(--space-4);
}
@media (min-width: 600px) {
.auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
}
.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;
@@ -690,28 +635,6 @@ button.forget-btn:hover {
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);
@@ -742,12 +665,9 @@ button.forget-btn:hover {
.oauth-login .actions {
display: flex;
gap: var(--space-4);
gap: var(--space-3);
margin-top: var(--space-2);
}
.oauth-login .actions button {
flex: 1;
justify-content: flex-end;
}
.passkey-unavailable {
@@ -854,12 +774,6 @@ button.forget-btn:hover {
background: var(--bg-secondary);
}
@media (min-width: 800px) {
.client-info {
text-align: left;
}
}
.client-logo {
width: 64px;
height: 64px;
@@ -892,21 +806,21 @@ button.forget-btn:hover {
margin-bottom: var(--space-6);
}
.consent-container .account-info .label {
.consent-account-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.consent-container .account-info .did {
.consent-account-did {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
word-break: break-all;
}
.consent-container .account-info .handle {
.consent-account-handle {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--text-primary);
@@ -1103,13 +1017,6 @@ button.forget-btn:hover {
margin-top: var(--space-6);
}
@media (min-width: 800px) {
.consent-container .actions {
max-width: 400px;
margin-left: auto;
}
}
.consent-container .actions button {
flex: 1;
padding: var(--space-3);
@@ -1308,10 +1215,6 @@ button.forget-btn:hover {
justify-content: center;
}
.oauth-register-container {
max-width: var(--width-lg);
}
.oauth-register-container .loading,
.oauth-register-container .creating {
display: flex;
@@ -1346,13 +1249,6 @@ button.forget-btn:hover {
margin-top: var(--space-2);
}
.secondary-actions {
display: flex;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
.oauth-register-container fieldset {
border: 1px solid var(--border-color);
padding: var(--space-4);
@@ -1363,10 +1259,6 @@ button.forget-btn:hover {
font-weight: var(--font-medium);
}
.sso-register-container {
max-width: var(--width-lg);
}
.sso-register-container .loading {
padding: var(--space-8);
}
@@ -1397,12 +1289,6 @@ button.forget-btn:hover {
margin-top: var(--space-3);
}
.color-pair {
display: flex;
gap: var(--space-2);
align-items: center;
}
.color-pair input[type="color"] {
width: 40px;
height: 36px;
@@ -1415,32 +1301,6 @@ button.forget-btn:hover {
flex: 1;
}
.swatch {
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-2);
font-size: var(--text-xs);
}
.spacing-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
align-items: flex-end;
}
.spacing-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.spacing-box {
background: var(--accent);
min-width: 2px;
min-height: 2px;
}
.key-choice-step {
display: flex;
flex-direction: column;
@@ -1576,3 +1436,60 @@ button.forget-btn:hover {
font-size: var(--text-sm);
margin-top: var(--space-1);
}
@media (min-width: 600px) {
.login-page .actions {
flex-direction: row;
}
.login-page .actions button {
flex: 1;
}
.oauth-login .auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
.auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
.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 (min-width: 800px) {
.client-info {
text-align: left;
}
.consent-container .actions {
max-width: 400px;
margin-left: auto;
}
}