'Clever' stateless token verification. We could revert later if it sucks.

This commit is contained in:
lewis
2025-12-24 23:57:49 +02:00
parent 1280f8044d
commit d8451f3219
77 changed files with 3029 additions and 1339 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET telegram_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8"
}
@@ -40,7 +40,8 @@
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert"
"legacy_login_alert",
"migration_verification"
]
}
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET signal_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af"
}
@@ -48,7 +48,8 @@
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert"
"legacy_login_alert",
"migration_verification"
]
}
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5"
}
@@ -40,7 +40,8 @@
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert"
"legacy_login_alert",
"migration_verification"
]
}
}
@@ -1,30 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
},
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483"
}
@@ -1,27 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
]
},
"nullable": []
},
"hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e"
}
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, email, email_verified FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"describe": {
"columns": [
{
@@ -42,11 +42,26 @@
},
{
"ordinal": 5,
"name": "discord_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "telegram_username",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "signal_number",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 6,
"ordinal": 9,
"name": "encryption_version",
"type_info": "Int4"
}
@@ -62,9 +77,12 @@
false,
true,
false,
true,
true,
true,
false,
true
]
},
"hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a"
"hash": "9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "pending_identifier",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email_verified = true WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "discord_verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "telegram_verified",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "signal_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6"
}
@@ -1,30 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
},
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET discord_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839"
}
@@ -1,47 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "pending_identifier",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
]
},
"nullable": [
false,
true,
false
]
},
"hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5"
}
@@ -43,7 +43,8 @@
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert"
"legacy_login_alert",
"migration_verification"
]
}
}
+27
View File
@@ -8,6 +8,7 @@
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
"npm:jsdom@^25.0.1": "25.0.1",
"npm:multiformats@^13.3.1": "13.4.2",
"npm:svelte-check@*": "4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
"npm:svelte@5": "5.45.10_acorn@8.15.0",
"npm:vite@*": "6.4.1_picomatch@4.0.3",
@@ -794,6 +795,12 @@
"check-error@2.1.1": {
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
},
"chokidar@4.0.3": {
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": [
"readdirp"
]
},
"cli-color@2.0.4": {
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
"dependencies": [
@@ -1339,6 +1346,9 @@
"react-is@17.0.2": {
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"redent@3.0.0": {
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dependencies": [
@@ -1417,6 +1427,19 @@
"min-indent"
]
},
"svelte-check@4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3": {
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
"dependencies": [
"@jridgewell/trace-mapping",
"chokidar",
"fdir",
"picocolors",
"sade",
"svelte",
"typescript"
],
"bin": true
},
"svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": {
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
"dependencies": [
@@ -1518,6 +1541,10 @@
"type@2.7.3": {
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
},
"typescript@5.9.3": {
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
"vite-node@2.1.9": {
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dependencies": [
+2 -1
View File
@@ -15,7 +15,8 @@
...rest
}: Props = $props()
let inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`
const fallbackId = `input-${Math.random().toString(36).slice(2, 9)}`
let inputId = $derived(id || fallbackId)
</script>
<div class="field">
@@ -33,10 +33,6 @@
padding: var(--space-6);
}
.section + .section {
margin-top: var(--space-6);
}
.section-danger {
background: var(--error-bg);
border: 1px solid var(--error-border);
+29 -2
View File
@@ -319,11 +319,11 @@ export const api = {
})
},
async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> {
async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> {
return xrpc('com.tranquil.account.confirmChannelVerification', {
method: 'POST',
token,
body: { channel, code },
body: { channel, identifier, code },
})
},
@@ -854,4 +854,31 @@ export const api = {
body: { did, recoveryToken, newPassword },
})
},
async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> {
return xrpc('com.atproto.server.verifyMigrationEmail', {
method: 'POST',
body: { token, email },
})
},
async resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
return xrpc('com.atproto.server.resendMigrationVerification', {
method: 'POST',
body: { email },
})
},
async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{
success: boolean
did: string
purpose: string
channel: string
}> {
return xrpc('com.tranquil.account.verifyToken', {
method: 'POST',
body: { token, identifier },
token: accessToken,
})
},
}
@@ -70,13 +70,13 @@
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder="Enter 6-digit code"
placeholder="XXXX-XXXX-XXXX-XXXX"
disabled={flow.state.submitting}
required
maxlength="6"
inputmode="numeric"
autocomplete="one-time-code"
class="code-input"
/>
<span class="hint">Copy the entire code from your message, including dashes.</span>
</div>
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
@@ -100,4 +100,17 @@
color: var(--text-secondary);
margin: 0;
}
.code-input {
font-family: var(--font-mono, monospace);
font-size: var(--text-base);
letter-spacing: 0.05em;
}
.hint {
display: block;
color: var(--text-secondary);
font-size: var(--text-sm);
margin-top: var(--space-1);
}
</style>
+130 -8
View File
@@ -164,7 +164,7 @@
"changeEmailButton": "Change Email",
"requesting": "Requesting...",
"verificationCode": "Verification Code",
"verificationCodePlaceholder": "Enter code from email",
"verificationCodePlaceholder": "Enter verification code",
"confirmEmailChange": "Confirm Email Change",
"updating": "Updating...",
"changeHandle": "Change Handle",
@@ -202,14 +202,14 @@
"deleteAccount": "Delete Account",
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
"requestDeletion": "Request Account Deletion",
"confirmationCode": "Confirmation Code (from email)",
"confirmationCode": "Confirmation Code",
"confirmationCodePlaceholder": "Enter confirmation code",
"yourPassword": "Your Password",
"yourPasswordPlaceholder": "Enter your password",
"permanentlyDelete": "Permanently Delete Account",
"deleting": "Deleting...",
"messages": {
"emailCodeSent": "Verification code sent to your current email",
"emailCodeSent": "Verification code sent to your notification channel",
"emailUpdated": "Email updated successfully",
"handleUpdated": "Handle updated successfully",
"passwordChanged": "Password changed successfully",
@@ -451,6 +451,28 @@
},
"admin": {
"title": "Admin Panel",
"loading": "Loading...",
"serverConfig": "Server Configuration",
"serverName": "Server Name",
"serverNamePlaceholder": "My PDS",
"serverNameHelp": "Displayed in the browser tab and other places",
"serverLogo": "Server Logo",
"logoPreview": "Logo preview",
"removeLogo": "Remove",
"logoHelp": "Used as favicon and shown in the navbar",
"themeColors": "Theme Colors",
"themeColorsHint": "Leave blank to use default colors.",
"primaryLight": "Primary (Light Mode)",
"primaryLightDefault": "#2c00ff (default)",
"primaryDark": "Primary (Dark Mode)",
"primaryDarkDefault": "#7b6bff (default)",
"secondaryLight": "Secondary (Light Mode)",
"secondaryLightDefault": "#ff2400 (default)",
"secondaryDark": "Secondary (Dark Mode)",
"secondaryDarkDefault": "#ff6b5b (default)",
"configSaved": "Server configuration saved",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"serverStats": "Server Statistics",
"users": "Users",
"repos": "Repositories",
@@ -580,20 +602,34 @@
"verify": {
"title": "Verify Your Account",
"subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.",
"codePlaceholder": "Enter 6-digit code",
"tokenSubtitle": "Enter the verification code and the identifier it was sent to.",
"tokenTitle": "Verify",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "Verification Code",
"codeHelp": "Copy the entire code from your message, including dashes",
"verifyButton": "Verify Account",
"verify": "Verify",
"verifying": "Verifying...",
"pleaseWait": "Please wait...",
"resendCode": "Resend Code",
"resending": "Resending...",
"sending": "Sending...",
"codeResent": "Verification code resent!",
"codeResentDetail": "Verification code sent! Check your inbox.",
"backToLogin": "Back to Login",
"verifyingAccount": "Verifying account: @{handle}",
"startOver": "Start over with a different account",
"noPending": "No pending verification found.",
"noPendingInfo": "If you recently created an account and need to verify it, you may need to create a new account. If you already verified your account, you can sign in.",
"createAccount": "Create Account",
"signIn": "Sign In"
"signIn": "Sign In",
"verified": "Verified!",
"channelVerified": "Your {channel} has been verified successfully.",
"canNowSignIn": "You can now sign in to your account.",
"continue": "Continue",
"identifierLabel": "Email or Identifier",
"identifierPlaceholder": "you@example.com",
"identifierHelp": "The email address or identifier the code was sent to"
},
"resetPassword": {
"title": "Reset Password",
@@ -605,7 +641,7 @@
"sendCode": "Send Reset Code",
"sending": "Sending...",
"codeSent": "Password reset code sent! Check your preferred notification channel.",
"enterCode": "Enter the code from your email and your new password.",
"enterCode": "Enter the code you received and your new password.",
"code": "Reset Code",
"codePlaceholder": "Enter reset code",
"newPassword": "New Password",
@@ -664,20 +700,86 @@
},
"registerPasskey": {
"title": "Create Passkey Account",
"subtitle": "Create a passwordless account using a passkey.",
"subtitle": "Create an ultra-secure account using a passkey instead of a password.",
"subtitleKeyChoice": "Choose how to set up your external did:web identity.",
"subtitleInitialDidDoc": "Upload your DID document to continue.",
"subtitleCreating": "Creating your account...",
"subtitlePasskey": "Register your passkey to secure your account.",
"subtitleAppPassword": "Save your app password for third-party apps.",
"subtitleVerify": "Verify your {channel} to continue.",
"subtitleUpdatedDidDoc": "Update your DID document with the PDS signing key.",
"subtitleActivating": "Activating your account...",
"subtitleComplete": "Your account has been created successfully!",
"handle": "Handle",
"handlePlaceholder": "yourname",
"handleHint": "Your full handle will be: @{handle}",
"handleDotWarning": "Custom domain handles can be set up after account creation.",
"email": "Email Address",
"emailPlaceholder": "you@example.com",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter your invite code",
"createButton": "Create Account",
"creating": "Creating...",
"continue": "Continue",
"back": "Back",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in",
"wantPassword": "Want to use a password?",
"createPasswordAccount": "Create a password account"
"createPasswordAccount": "Create a password account",
"wantTraditional": "Want a traditional password?",
"registerWithPassword": "Register with password",
"contactMethod": "Contact Method",
"contactMethodHint": "Choose how you'd like to verify your account and receive notifications.",
"verificationMethod": "Verification Method",
"identityType": "Identity Type",
"identityTypeHint": "Choose how your decentralized identity will be managed.",
"didPlcRecommended": "did:plc (Recommended)",
"didPlcHint": "Portable identity managed by PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identity hosted on this PDS (read warning below)",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "Bring your own domain",
"didWebWarningTitle": "Important: Understand the trade-offs",
"didWebWarning1": "Permanent tie to this PDS:",
"didWebWarning2": "No recovery mechanism:",
"didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.",
"didWebWarning3": "We commit to you:",
"didWebWarning3Detail": "If you migrate away, we will continue serving a minimal DID document.",
"didWebWarning4": "Recommendation:",
"didWebWarning4Detail": "Choose did:plc unless you have a specific reason to prefer did:web.",
"externalDid": "Your did:web",
"externalDidPlaceholder": "did:web:yourdomain.com",
"externalDidHint": "You'll need to serve a DID document at",
"whyPasskeyOnly": "Why passkey-only?",
"whyPasskeyOnlyDesc": "Passkey accounts are more secure than password-based accounts because they:",
"whyPasskeyBullet1": "Cannot be phished or stolen in data breaches",
"whyPasskeyBullet2": "Use hardware-backed cryptographic keys",
"whyPasskeyBullet3": "Require your biometric or device PIN to use",
"passkeyNameLabel": "Passkey Name (optional)",
"passkeyNamePlaceholder": "e.g., MacBook Touch ID",
"passkeyNameHint": "A friendly name to identify this passkey",
"passkeyPrompt": "Click the button below to create your passkey. You'll be prompted to use:",
"passkeyPromptBullet1": "Touch ID or Face ID",
"passkeyPromptBullet2": "Your device PIN or password",
"passkeyPromptBullet3": "A security key (if you have one)",
"createPasskey": "Create Passkey",
"creatingPasskey": "Creating Passkey...",
"redirecting": "Redirecting to dashboard...",
"loading": "Loading...",
"errors": {
"handleRequired": "Handle is required",
"handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.",
"inviteRequired": "Invite code is required",
"externalDidRequired": "External did:web is required",
"externalDidFormat": "External DID must start with did:web:",
"emailRequired": "Email is required for email verification",
"discordRequired": "Discord ID is required for Discord verification",
"telegramRequired": "Telegram username is required for Telegram verification",
"signalRequired": "Phone number is required for Signal verification",
"passkeysNotSupported": "Passkeys are not supported in this browser. Please use a different browser or register with a password instead.",
"passkeyCancelled": "Passkey creation was cancelled",
"passkeyFailed": "Passkey registration failed"
}
},
"trustedDevices": {
"title": "Trusted Devices",
@@ -710,5 +812,25 @@
"verify": "Verify",
"verifying": "Verifying...",
"cancel": "Cancel"
},
"verifyChannel": {
"title": "Verify Channel",
"subtitle": "Enter the verification code sent to your notification channel.",
"signInRequired": "Sign In Required",
"signInRequiredDesc": "You must be signed in to verify a channel.",
"signIn": "Sign In",
"verifying": "Verifying...",
"pleaseWait": "Please wait while we verify your channel.",
"successTitle": "Verified!",
"successDesc": "Your {channel} has been verified successfully.",
"backToSettings": "Back to Settings",
"channelLabel": "Channel",
"selectChannel": "Select channel...",
"identifierLabel": "Identifier",
"identifierPlaceholder": "Email, Discord ID, etc.",
"identifierHelp": "The email address, Discord ID, Telegram username, or Signal number being verified.",
"codeLabel": "Verification Code",
"codeHelp": "Copy the entire code from your message, including dashes.",
"verifyButton": "Verify"
}
}
+91 -7
View File
@@ -164,7 +164,7 @@
"changeEmailButton": "Vaihda sähköposti",
"requesting": "Pyydetään...",
"verificationCode": "Vahvistuskoodi",
"verificationCodePlaceholder": "Syötä koodi sähköpostista",
"verificationCodePlaceholder": "Syötä vahvistuskoodi",
"confirmEmailChange": "Vahvista sähköpostin vaihto",
"updating": "Päivitetään...",
"changeHandle": "Vaihda käyttäjänimi",
@@ -202,14 +202,14 @@
"deleteAccount": "Poista tili",
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
"requestDeletion": "Pyydä tilin poistoa",
"confirmationCode": "Vahvistuskoodi (sähköpostista)",
"confirmationCode": "Vahvistuskoodi",
"confirmationCodePlaceholder": "Syötä vahvistuskoodi",
"yourPassword": "Salasanasi",
"yourPasswordPlaceholder": "Syötä salasanasi",
"permanentlyDelete": "Poista tili pysyvästi",
"deleting": "Poistetaan...",
"messages": {
"emailCodeSent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiisi",
"emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi",
"emailUpdated": "Sähköposti päivitetty",
"handleUpdated": "Käyttäjänimi päivitetty",
"passwordChanged": "Salasana vaihdettu",
@@ -451,6 +451,25 @@
},
"admin": {
"title": "Ylläpitopaneeli",
"loading": "Ladataan...",
"serverConfig": "Palvelinasetukset",
"serverName": "Palvelimen nimi",
"serverNamePlaceholder": "Oma PDS",
"serverNameHelp": "Näytetään selaimen välilehdessä ja muualla",
"serverLogo": "Palvelimen logo",
"logoPreview": "Logon esikatselu",
"removeLogo": "Poista",
"logoHelp": "Käytetään faviconina ja näytetään navigointipalkissa",
"themeColors": "Teemavärit",
"themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.",
"primaryLight": "Ensisijainen (vaalea tila)",
"primaryDark": "Ensisijainen (tumma tila)",
"accentLight": "Korostus (vaalea tila)",
"accentDark": "Korostus (tumma tila)",
"faviconExample": "Favicon-esimerkki",
"configSaved": "Palvelinasetukset tallennettu",
"saving": "Tallennetaan...",
"saveConfig": "Tallenna asetukset",
"serverStats": "Palvelintilastot",
"users": "Käyttäjät",
"repos": "Tietovarastot",
@@ -580,13 +599,27 @@
"verify": {
"title": "Vahvista tilisi",
"subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.",
"codePlaceholder": "Syötä 6-numeroinen koodi",
"tokenTitle": "Vahvista",
"tokenSubtitle": "Syötä vahvistuskoodi ja tunniste, johon se lähetettiin.",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "Vahvistuskoodi",
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
"verifyButton": "Vahvista tili",
"verify": "Vahvista",
"verifying": "Vahvistetaan...",
"pleaseWait": "Odota...",
"sending": "Lähetetään...",
"resendCode": "Lähetä koodi uudelleen",
"resending": "Lähetetään uudelleen...",
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
"verified": "Vahvistettu!",
"channelVerified": "{channel} on vahvistettu onnistuneesti.",
"canNowSignIn": "Voit nyt kirjautua tilillesi.",
"continue": "Jatka",
"identifierLabel": "Sähköposti tai tunniste",
"identifierPlaceholder": "sinä@esimerkki.fi",
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
"backToLogin": "Takaisin kirjautumiseen",
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
"startOver": "Aloita alusta toisella tilillä",
@@ -605,7 +638,7 @@
"sendCode": "Lähetä palautuskoodi",
"sending": "Lähetetään...",
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
"enterCode": "Syötä koodi sähköpostistasi ja uusi salasanasi.",
"enterCode": "Syötä saamasi koodi ja uusi salasanasi.",
"code": "Palautuskoodi",
"codePlaceholder": "Syötä palautuskoodi",
"newPassword": "Uusi salasana",
@@ -664,20 +697,51 @@
},
"registerPasskey": {
"title": "Luo pääsyavaintili",
"subtitle": "Luo salasanaton tili pääsyavaimella.",
"subtitle": "Luo erittäin turvallinen tili käyttämällä pääsyavainta salasanan sijaan.",
"subtitleKeyChoice": "Valitse, miten haluat määrittää ulkoisen did:web-identiteettisi.",
"subtitleVerify": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä koodi jatkaaksesi.",
"subtitlePasskey": "Luo pääsyavain viimeistelläksesi tilin määrityksen.",
"handle": "Käyttäjänimi",
"handlePlaceholder": "nimesi",
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
"contactMethod": "Yhteysmenetelmä",
"contactMethodHint": "Valitse, miten haluat vahvistaa tilisi ja vastaanottaa ilmoituksia.",
"verificationMethod": "Vahvistusmenetelmä",
"email": "Sähköpostiosoite",
"emailPlaceholder": "sinä@esimerkki.fi",
"discord": "Discord",
"discordId": "Discord-käyttäjätunnus",
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
"telegram": "Telegram",
"telegramUsername": "Telegram-käyttäjänimi",
"telegramUsernamePlaceholder": "@käyttäjänimesi",
"signal": "Signal",
"signalNumber": "Signal-puhelinnumero",
"signalNumberPlaceholder": "+358401234567",
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
"inviteCode": "Kutsukoodi",
"inviteCodePlaceholder": "Syötä kutsukoodisi",
"inviteCodeRequired": "vaaditaan",
"didWebDescription": "Käytä DID-identiteettiä, jota isännöidään omalla verkkotunnuksellasi.",
"didWebToggle": "Käytä ulkoista did:web",
"externalDid": "Sinun did:web",
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
"dnsVerificationInstructions": "Vahvistaaksesi verkkotunnuksesi, lisää tämä TXT-tietue:",
"copyDid": "Kopioi DID",
"createButton": "Luo tili",
"creating": "Luodaan...",
"alreadyHaveAccount": "Onko sinulla jo tili?",
"signIn": "Kirjaudu sisään",
"wantPassword": "Haluatko käyttää salasanaa?",
"createPasswordAccount": "Luo salasanatili"
"createPasswordAccount": "Luo salasanatili",
"errors": {
"handleRequired": "Käyttäjänimi vaaditaan",
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui"
}
},
"trustedDevices": {
"title": "Luotetut laitteet",
@@ -710,5 +774,25 @@
"verify": "Vahvista",
"verifying": "Vahvistetaan...",
"cancel": "Peruuta"
},
"verifyChannel": {
"title": "Vahvista kanava",
"subtitle": "Syötä ilmoituskanavallesi lähetetty vahvistuskoodi.",
"signInRequired": "Kirjautuminen vaaditaan",
"signInRequiredDesc": "Sinun on kirjauduttava sisään vahvistaaksesi kanavan.",
"signIn": "Kirjaudu sisään",
"verifying": "Vahvistetaan...",
"pleaseWait": "Odota, vahvistamme kanavaasi.",
"successTitle": "Vahvistettu!",
"successDesc": "{channel} on vahvistettu onnistuneesti.",
"backToSettings": "Takaisin asetuksiin",
"channelLabel": "Kanava",
"selectChannel": "Valitse kanava...",
"identifierLabel": "Tunniste",
"identifierPlaceholder": "Sähköposti, Discord ID jne.",
"identifierHelp": "Vahvistettava sähköpostiosoite, Discord ID, Telegram-käyttäjänimi tai Signal-numero.",
"codeLabel": "Vahvistuskoodi",
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat.",
"verifyButton": "Vahvista"
}
}
+92 -8
View File
@@ -65,7 +65,7 @@
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
"didWeb": "did:web",
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
"didWebBYOD": "did:web (BYOD)",
"didWebBYOD": "did:web (自前ドメイン)",
"didWebBYODHint": "独自ドメインを持ち込む",
"didWebWarningTitle": "重要: トレードオフをご理解ください",
"didWebWarning1": "この PDS への永続的な紐付け:",
@@ -164,7 +164,7 @@
"changeEmailButton": "メールを変更",
"requesting": "リクエスト中...",
"verificationCode": "確認コード",
"verificationCodePlaceholder": "メールから受け取ったコードを入力",
"verificationCodePlaceholder": "認証コードを入力",
"confirmEmailChange": "メール変更を確認",
"updating": "更新中...",
"changeHandle": "ハンドル変更",
@@ -202,14 +202,14 @@
"deleteAccount": "アカウント削除",
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
"requestDeletion": "アカウント削除をリクエスト",
"confirmationCode": "確認コード(メールから)",
"confirmationCode": "確認コード",
"confirmationCodePlaceholder": "確認コードを入力",
"yourPassword": "パスワード",
"yourPasswordPlaceholder": "パスワードを入力",
"permanentlyDelete": "アカウントを完全に削除",
"deleting": "削除中...",
"messages": {
"emailCodeSent": "現在のメールに確認コードを送信しました",
"emailCodeSent": "通知チャンネルに確認コードを送信しました",
"emailUpdated": "メールを更新しました",
"handleUpdated": "ハンドルを更新しました",
"passwordChanged": "パスワードを変更しました",
@@ -451,6 +451,25 @@
},
"admin": {
"title": "管理パネル",
"loading": "読み込み中...",
"serverConfig": "サーバー設定",
"serverName": "サーバー名",
"serverNamePlaceholder": "マイ PDS",
"serverNameHelp": "ブラウザのタブやその他の場所に表示されます",
"serverLogo": "サーバーロゴ",
"logoPreview": "ロゴプレビュー",
"removeLogo": "削除",
"logoHelp": "ファビコンとして使用され、ナビバーに表示されます",
"themeColors": "テーマカラー",
"themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。",
"primaryLight": "プライマリ(ライトモード)",
"primaryDark": "プライマリ(ダークモード)",
"accentLight": "アクセント(ライトモード)",
"accentDark": "アクセント(ダークモード)",
"faviconExample": "ファビコン例",
"configSaved": "サーバー設定を保存しました",
"saving": "保存中...",
"saveConfig": "設定を保存",
"serverStats": "サーバー統計",
"users": "ユーザー",
"repos": "リポジトリ",
@@ -580,13 +599,27 @@
"verify": {
"title": "アカウント確認",
"subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。",
"codePlaceholder": "6桁のコードを入力",
"tokenTitle": "確認",
"tokenSubtitle": "確認コードと送信先の識別子を入力してください。",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "確認コード",
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
"verifyButton": "アカウントを確認",
"verify": "確認",
"verifying": "確認中...",
"pleaseWait": "お待ちください...",
"sending": "送信中...",
"resendCode": "コードを再送信",
"resending": "送信中...",
"codeResent": "確認コードを再送信しました!",
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
"verified": "確認完了!",
"channelVerified": "{channel} が正常に確認されました。",
"canNowSignIn": "アカウントにサインインできるようになりました。",
"continue": "続行",
"identifierLabel": "メールまたは識別子",
"identifierPlaceholder": "you@example.com",
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
"backToLogin": "ログインに戻る",
"verifyingAccount": "確認中のアカウント: @{handle}",
"startOver": "別のアカウントでやり直す",
@@ -605,7 +638,7 @@
"sendCode": "リセットコードを送信",
"sending": "送信中...",
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
"enterCode": "メールからのコードと新しいパスワードを入力してください。",
"enterCode": "受け取ったコードと新しいパスワードを入力してください。",
"code": "リセットコード",
"codePlaceholder": "リセットコードを入力",
"newPassword": "新しいパスワード",
@@ -664,20 +697,51 @@
},
"registerPasskey": {
"title": "パスキーアカウントを作成",
"subtitle": "パスキーを使用してパスワードレスアカウントを作成します。",
"subtitle": "パスワードの代わりにパスキーを使用して超安全なアカウントを作成します。",
"subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。",
"subtitleVerify": "{channel} に確認コードを送信しました。コードを入力して続行してください。",
"subtitlePasskey": "パスキーを作成してアカウント設定を完了します。",
"handle": "ハンドル",
"handlePlaceholder": "あなたの名前",
"handleHint": "完全なハンドル: @{handle}",
"contactMethod": "連絡方法",
"contactMethodHint": "アカウントの確認と通知の受信方法を選択してください。",
"verificationMethod": "確認方法",
"email": "メールアドレス",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord ユーザー ID",
"discordIdPlaceholder": "Discord ユーザー ID",
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
"telegram": "Telegram",
"telegramUsername": "Telegram ユーザー名",
"telegramUsernamePlaceholder": "@yourusername",
"signal": "Signal",
"signalNumber": "Signal 電話番号",
"signalNumberPlaceholder": "+81XXXXXXXXXX",
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
"inviteCode": "招待コード",
"inviteCodePlaceholder": "招待コードを入力",
"inviteCodeRequired": "必須",
"didWebDescription": "独自ドメインでホストされる DID アイデンティティを使用します。",
"didWebToggle": "外部 did:web を使用",
"externalDid": "あなたの did:web",
"externalDidPlaceholder": "did:web:yourdomain.com",
"dnsVerificationInstructions": "ドメインを確認するには、この TXT レコードを追加してください:",
"copyDid": "DID をコピー",
"createButton": "アカウントを作成",
"creating": "作成中...",
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"signIn": "サインイン",
"wantPassword": "パスワードを使用しますか?",
"createPasswordAccount": "パスワードアカウントを作成"
"createPasswordAccount": "パスワードアカウントを作成",
"errors": {
"handleRequired": "ハンドルは必須です",
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
"passkeyCancelled": "パスキーの作成がキャンセルされました",
"passkeyFailed": "パスキーの登録に失敗しました"
}
},
"trustedDevices": {
"title": "信頼済みデバイス",
@@ -710,5 +774,25 @@
"verify": "確認",
"verifying": "確認中...",
"cancel": "キャンセル"
},
"verifyChannel": {
"title": "チャンネル認証",
"subtitle": "通知チャンネルに送信された認証コードを入力してください。",
"signInRequired": "ログインが必要です",
"signInRequiredDesc": "チャンネルを認証するにはログインが必要です。",
"signIn": "ログイン",
"verifying": "認証中...",
"pleaseWait": "チャンネルを認証しています。しばらくお待ちください。",
"successTitle": "認証完了!",
"successDesc": "{channel} が正常に認証されました。",
"backToSettings": "設定に戻る",
"channelLabel": "チャンネル",
"selectChannel": "チャンネルを選択...",
"identifierLabel": "識別子",
"identifierPlaceholder": "メール、Discord ID など",
"identifierHelp": "認証するメールアドレス、Discord ID、Telegram ユーザー名、または Signal 番号。",
"codeLabel": "認証コード",
"codeHelp": "メッセージからハイフンを含む完全なコードをコピーしてください。",
"verifyButton": "認証"
}
}
+92 -8
View File
@@ -65,7 +65,7 @@
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
"didWeb": "did:web",
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
"didWebBYOD": "did:web (BYOD)",
"didWebBYOD": "did:web (자체 도메인)",
"didWebBYODHint": "자체 도메인 사용",
"didWebWarningTitle": "중요: 장단점을 이해하세요",
"didWebWarning1": "이 PDS에 영구 연결:",
@@ -164,7 +164,7 @@
"changeEmailButton": "이메일 변경",
"requesting": "요청 중...",
"verificationCode": "인증 코드",
"verificationCodePlaceholder": "이메일의 코드 입력",
"verificationCodePlaceholder": "인증 코드 입력",
"confirmEmailChange": "이메일 변경 확인",
"updating": "업데이트 중...",
"changeHandle": "핸들 변경",
@@ -202,14 +202,14 @@
"deleteAccount": "계정 삭제",
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
"requestDeletion": "계정 삭제 요청",
"confirmationCode": "확인 코드 (이메일에서)",
"confirmationCode": "확인 코드",
"confirmationCodePlaceholder": "확인 코드 입력",
"yourPassword": "비밀번호",
"yourPasswordPlaceholder": "비밀번호 입력",
"permanentlyDelete": "계정 영구 삭제",
"deleting": "삭제 중...",
"messages": {
"emailCodeSent": "현재 이메일로 인증 코드를 보냈습니다",
"emailCodeSent": "알림 채널로 인증 코드를 보냈습니다",
"emailUpdated": "이메일이 업데이트되었습니다",
"handleUpdated": "핸들이 업데이트되었습니다",
"passwordChanged": "비밀번호가 변경되었습니다",
@@ -451,6 +451,25 @@
},
"admin": {
"title": "관리 패널",
"loading": "로딩 중...",
"serverConfig": "서버 설정",
"serverName": "서버 이름",
"serverNamePlaceholder": "내 PDS",
"serverNameHelp": "브라우저 탭 및 다른 곳에 표시됩니다",
"serverLogo": "서버 로고",
"logoPreview": "로고 미리보기",
"removeLogo": "삭제",
"logoHelp": "파비콘으로 사용되며 네비게이션 바에 표시됩니다",
"themeColors": "테마 색상",
"themeColorsHint": "기본 색상을 사용하려면 비워 두세요.",
"primaryLight": "기본 (라이트 모드)",
"primaryDark": "기본 (다크 모드)",
"accentLight": "강조 (라이트 모드)",
"accentDark": "강조 (다크 모드)",
"faviconExample": "파비콘 예시",
"configSaved": "서버 설정이 저장되었습니다",
"saving": "저장 중...",
"saveConfig": "설정 저장",
"serverStats": "서버 통계",
"users": "사용자",
"repos": "저장소",
@@ -580,13 +599,27 @@
"verify": {
"title": "계정 인증",
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.",
"codePlaceholder": "6자리 코드 입력",
"tokenTitle": "인증",
"tokenSubtitle": "인증 코드와 전송된 식별자를 입력하세요.",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "인증 코드",
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
"verifyButton": "계정 인증",
"verify": "인증",
"verifying": "인증 중...",
"pleaseWait": "잠시 기다려 주세요...",
"sending": "전송 중...",
"resendCode": "코드 다시 보내기",
"resending": "전송 중...",
"codeResent": "인증 코드를 다시 보냈습니다!",
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
"verified": "인증 완료!",
"channelVerified": "{channel}이(가) 성공적으로 인증되었습니다.",
"canNowSignIn": "이제 계정에 로그인할 수 있습니다.",
"continue": "계속",
"identifierLabel": "이메일 또는 식별자",
"identifierPlaceholder": "you@example.com",
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
"backToLogin": "로그인으로 돌아가기",
"verifyingAccount": "인증 중인 계정: @{handle}",
"startOver": "다른 계정으로 다시 시작",
@@ -605,7 +638,7 @@
"sendCode": "재설정 코드 보내기",
"sending": "전송 중...",
"codeSent": "비밀번호 재설정 코드를 보냈습니다! 선호하는 알림 채널을 확인하세요.",
"enterCode": "이메일의 코드와 새 비밀번호를 입력하세요.",
"enterCode": "받은 코드와 새 비밀번호를 입력하세요.",
"code": "재설정 코드",
"codePlaceholder": "재설정 코드 입력",
"newPassword": "새 비밀번호",
@@ -664,20 +697,51 @@
},
"registerPasskey": {
"title": "패스키 계정 만들기",
"subtitle": "패스키를 사용하여 비밀번호 없는 계정을 만듭니다.",
"subtitle": "비밀번호 대신 패스키를 사용하여 초안전 계정을 만듭니다.",
"subtitleKeyChoice": "외부 did:web 아이덴티티 설정 방법을 선택하세요.",
"subtitleVerify": "{channel}(으)로 인증 코드를 보냈습니다. 코드를 입력하여 계속하세요.",
"subtitlePasskey": "패스키를 만들어 계정 설정을 완료하세요.",
"handle": "핸들",
"handlePlaceholder": "사용자 이름",
"handleHint": "전체 핸들: @{handle}",
"contactMethod": "연락 방법",
"contactMethodHint": "계정 인증 및 알림 수신 방법을 선택하세요.",
"verificationMethod": "인증 방법",
"email": "이메일 주소",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord 사용자 ID",
"discordIdPlaceholder": "Discord 사용자 ID",
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
"telegram": "Telegram",
"telegramUsername": "Telegram 사용자 이름",
"telegramUsernamePlaceholder": "@yourusername",
"signal": "Signal",
"signalNumber": "Signal 전화번호",
"signalNumberPlaceholder": "+821012345678",
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
"inviteCode": "초대 코드",
"inviteCodePlaceholder": "초대 코드 입력",
"inviteCodeRequired": "필수",
"didWebDescription": "자체 도메인에서 호스팅되는 DID 아이덴티티를 사용합니다.",
"didWebToggle": "외부 did:web 사용",
"externalDid": "귀하의 did:web",
"externalDidPlaceholder": "did:web:yourdomain.com",
"dnsVerificationInstructions": "도메인을 인증하려면 이 TXT 레코드를 추가하세요:",
"copyDid": "DID 복사",
"createButton": "계정 만들기",
"creating": "생성 중...",
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"signIn": "로그인",
"wantPassword": "비밀번호를 사용하시겠습니까?",
"createPasswordAccount": "비밀번호 계정 만들기"
"createPasswordAccount": "비밀번호 계정 만들기",
"errors": {
"handleRequired": "핸들은 필수입니다",
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
"passkeyCancelled": "패스키 생성이 취소되었습니다",
"passkeyFailed": "패스키 등록에 실패했습니다"
}
},
"trustedDevices": {
"title": "신뢰할 수 있는 기기",
@@ -710,5 +774,25 @@
"verify": "확인",
"verifying": "확인 중...",
"cancel": "취소"
},
"verifyChannel": {
"title": "채널 인증",
"subtitle": "알림 채널로 전송된 인증 코드를 입력하세요.",
"signInRequired": "로그인 필요",
"signInRequiredDesc": "채널을 인증하려면 로그인해야 합니다.",
"signIn": "로그인",
"verifying": "인증 중...",
"pleaseWait": "채널을 인증하는 중입니다. 잠시 기다려 주세요.",
"successTitle": "인증 완료!",
"successDesc": "{channel}이(가) 성공적으로 인증되었습니다.",
"backToSettings": "설정으로 돌아가기",
"channelLabel": "채널",
"selectChannel": "채널 선택...",
"identifierLabel": "식별자",
"identifierPlaceholder": "이메일, Discord ID 등",
"identifierHelp": "인증할 이메일 주소, Discord ID, Telegram 사용자 이름 또는 Signal 번호.",
"codeLabel": "인증 코드",
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요.",
"verifyButton": "인증"
}
}
+98 -14
View File
@@ -80,7 +80,7 @@
"externalDidPlaceholder": "did:web:dindomän.se",
"externalDidHint": "Din domän måste tillhandahålla ett giltigt DID-dokument på /.well-known/did.json som pekar på denna PDS",
"contactMethod": "Kontaktmetod",
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot notiser. Du behöver bara en.",
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot meddelanden. Du behöver bara en.",
"verificationMethod": "Verifieringsmetod",
"email": "E-post",
"emailAddress": "E-postadress",
@@ -164,7 +164,7 @@
"changeEmailButton": "Ändra e-post",
"requesting": "Begär...",
"verificationCode": "Verifieringskod",
"verificationCodePlaceholder": "Ange kod från e-post",
"verificationCodePlaceholder": "Ange verifieringskod",
"confirmEmailChange": "Bekräfta e-poständring",
"updating": "Uppdaterar...",
"changeHandle": "Ändra användarnamn",
@@ -202,14 +202,14 @@
"deleteAccount": "Radera konto",
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
"requestDeletion": "Begär kontoradering",
"confirmationCode": "Bekräftelsekod (från e-post)",
"confirmationCode": "Bekräftelsekod",
"confirmationCodePlaceholder": "Ange bekräftelsekod",
"yourPassword": "Ditt lösenord",
"yourPasswordPlaceholder": "Ange ditt lösenord",
"permanentlyDelete": "Radera konto permanent",
"deleting": "Raderar...",
"messages": {
"emailCodeSent": "Verifieringskod skickad till din nuvarande e-post",
"emailCodeSent": "Verifieringskod skickad till din meddelandekanal",
"emailUpdated": "E-post uppdaterad",
"handleUpdated": "Användarnamn uppdaterat",
"passwordChanged": "Lösenord ändrat",
@@ -350,11 +350,11 @@
"lastUsed": "Senast använd",
"passwordDescription": "Hantera ditt kontolösenord. Om du har nycklar konfigurerade kan du valfritt ta bort ditt lösenord för en helt lösenordsfri upplevelse.",
"disableTotpWarning": "Detta gör ditt konto mindre säkert.",
"removePasswordWarning": "Detta gör ditt konto till endast nyckelkonto. Du kan endast logga in med dina registrerade nycklar. Om du förlorar tillgång till alla dina nycklar kan du återställa ditt konto via din notifieringskanal.",
"removePasswordWarning": "Detta gör ditt konto till endast nyckelkonto. Du kan endast logga in med dina registrerade nycklar. Om du förlorar tillgång till alla dina nycklar kan du återställa ditt konto via din meddelandekanal.",
"beforeProceeding": "Innan du fortsätter:",
"beforeProceedingItem1": "Se till att du har minst en pålitlig nyckel registrerad",
"beforeProceedingItem2": "Överväg att registrera nycklar på flera enheter",
"beforeProceedingItem3": "Se till att din återställningsnotifieringskanal är uppdaterad",
"beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad",
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
"passkeyOnlyHint": "Du loggar in med endast nycklar. Om du förlorar tillgång till dina nycklar kan du återställa ditt konto med länken \"Tappat bort nyckeln?\" på inloggningssidan.",
"trustedDevices": "Betrodda enheter",
@@ -451,6 +451,25 @@
},
"admin": {
"title": "Adminpanel",
"loading": "Laddar...",
"serverConfig": "Serverkonfiguration",
"serverName": "Servernamn",
"serverNamePlaceholder": "Min PDS",
"serverNameHelp": "Visas i webbläsarfliken och på andra ställen",
"serverLogo": "Serverlogotyp",
"logoPreview": "Förhandsgranskning av logotyp",
"removeLogo": "Ta bort",
"logoHelp": "Används som favicon och visas i navigeringsfältet",
"themeColors": "Temafärger",
"themeColorsHint": "Lämna tomt för att använda standardfärger.",
"primaryLight": "Primär (ljust läge)",
"primaryDark": "Primär (mörkt läge)",
"accentLight": "Accent (ljust läge)",
"accentDark": "Accent (mörkt läge)",
"faviconExample": "Favicon-exempel",
"configSaved": "Serverkonfiguration sparad",
"saving": "Sparar...",
"saveConfig": "Spara konfiguration",
"serverStats": "Serverstatistik",
"users": "Användare",
"repos": "Dataförvar",
@@ -514,7 +533,7 @@
"readProfile": "Läsa din profilinformation",
"readPosts": "Läsa dina inlägg och innehåll",
"writePosts": "Skapa och radera inlägg för din räkning",
"readNotifications": "Läsa dina notiser",
"readNotifications": "Läsa dina aviseringar",
"fullAccess": "Full tillgång till ditt konto",
"authorize": "Auktorisera",
"deny": "Neka",
@@ -580,13 +599,27 @@
"verify": {
"title": "Verifiera ditt konto",
"subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.",
"codePlaceholder": "Ange 6-siffrig kod",
"tokenTitle": "Verifiera",
"tokenSubtitle": "Ange verifieringskoden och identifieraren den skickades till.",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "Verifieringskod",
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
"verifyButton": "Verifiera konto",
"verify": "Verifiera",
"verifying": "Verifierar...",
"pleaseWait": "Vänta...",
"sending": "Skickar...",
"resendCode": "Skicka kod igen",
"resending": "Skickar igen...",
"codeResent": "Verifieringskod skickad igen!",
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
"verified": "Verifierad!",
"channelVerified": "Din {channel} har verifierats.",
"canNowSignIn": "Du kan nu logga in på ditt konto.",
"continue": "Fortsätt",
"identifierLabel": "E-post eller identifierare",
"identifierPlaceholder": "du@exempel.se",
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
"backToLogin": "Tillbaka till inloggning",
"verifyingAccount": "Verifierar konto: @{handle}",
"startOver": "Börja om med ett annat konto",
@@ -604,8 +637,8 @@
"emailPlaceholder": "användarnamn eller du@exempel.se",
"sendCode": "Skicka återställningskod",
"sending": "Skickar...",
"codeSent": "Återställningskod skickad! Kontrollera din föredragna notifieringskanal.",
"enterCode": "Ange koden från din e-post och ditt nya lösenord.",
"codeSent": "Återställningskod skickad! Kontrollera din föredragna meddelandekanal.",
"enterCode": "Ange koden du fick och ditt nya lösenord.",
"code": "Återställningskod",
"codePlaceholder": "Ange återställningskod",
"newPassword": "Nytt lösenord",
@@ -652,32 +685,63 @@
"title": "Återställ nyckelkonto",
"subtitle": "Förlorat tillgång till din nyckel? Ange ditt användarnamn eller e-post så skickar vi dig en återställningslänk.",
"successTitle": "Återställningslänk skickad",
"successMessage": "Om ditt konto finns och är ett endast nyckelkonto får du en återställningslänk på din föredragna notifieringskanal.",
"successMessage": "Om ditt konto finns och är ett endast nyckelkonto får du en återställningslänk på din föredragna meddelandekanal.",
"successInfo": "Länken upphör om 1 timme. Kontrollera din e-post, Discord, Telegram eller Signal beroende på dina kontoinställningar.",
"handleOrEmail": "Användarnamn eller e-post",
"emailPlaceholder": "användarnamn eller du@exempel.se",
"howItWorks": "Så fungerar det",
"howItWorksDetail": "Vi skickar en säker länk till din registrerade notifieringskanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
"howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
"sendRecoveryLink": "Skicka återställningslänk",
"sending": "Skickar...",
"backToLogin": "Tillbaka till inloggning"
},
"registerPasskey": {
"title": "Skapa nyckelkonto",
"subtitle": "Skapa ett lösenordsfritt konto med en nyckel.",
"subtitle": "Skapa ett ultrasäkert konto med en nyckel istället för ett lösenord.",
"subtitleKeyChoice": "Välj hur du vill konfigurera din externa did:web-identitet.",
"subtitleVerify": "Vi har skickat en verifieringskod till din {channel}. Ange koden för att fortsätta.",
"subtitlePasskey": "Skapa din nyckel för att slutföra kontokonfigurationen.",
"handle": "Användarnamn",
"handlePlaceholder": "dittnamn",
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
"contactMethod": "Kontaktmetod",
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot meddelanden.",
"verificationMethod": "Verifieringsmetod",
"email": "E-postadress",
"emailPlaceholder": "du@exempel.se",
"discord": "Discord",
"discordId": "Discord användar-ID",
"discordIdPlaceholder": "Ditt Discord användar-ID",
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
"telegram": "Telegram",
"telegramUsername": "Telegram-användarnamn",
"telegramUsernamePlaceholder": "@dittanvändarnamn",
"signal": "Signal",
"signalNumber": "Signal-telefonnummer",
"signalNumberPlaceholder": "+46701234567",
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
"inviteCode": "Inbjudningskod",
"inviteCodePlaceholder": "Ange din inbjudningskod",
"inviteCodeRequired": "krävs",
"didWebDescription": "Använd en DID-identitet som är lagrad på din egen domän.",
"didWebToggle": "Använd extern did:web",
"externalDid": "Din did:web",
"externalDidPlaceholder": "did:web:dindomän.se",
"dnsVerificationInstructions": "För att verifiera din domän, lägg till denna TXT-post:",
"copyDid": "Kopiera DID",
"createButton": "Skapa konto",
"creating": "Skapar...",
"alreadyHaveAccount": "Har du redan ett konto?",
"signIn": "Logga in",
"wantPassword": "Vill du använda ett lösenord?",
"createPasswordAccount": "Skapa ett lösenordskonto"
"createPasswordAccount": "Skapa ett lösenordskonto",
"errors": {
"handleRequired": "Användarnamn krävs",
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
"passkeyCancelled": "Nyckelskapande avbröts",
"passkeyFailed": "Nyckelregistrering misslyckades"
}
},
"trustedDevices": {
"title": "Betrodda enheter",
@@ -710,5 +774,25 @@
"verify": "Verifiera",
"verifying": "Verifierar...",
"cancel": "Avbryt"
},
"verifyChannel": {
"title": "Verifiera kanal",
"subtitle": "Ange verifieringskoden som skickades till din meddelandekanal.",
"signInRequired": "Inloggning krävs",
"signInRequiredDesc": "Du måste vara inloggad för att verifiera en kanal.",
"signIn": "Logga in",
"verifying": "Verifierar...",
"pleaseWait": "Vänta medan vi verifierar din kanal.",
"successTitle": "Verifierad!",
"successDesc": "Din {channel} har verifierats.",
"backToSettings": "Tillbaka till inställningar",
"channelLabel": "Kanal",
"selectChannel": "Välj kanal...",
"identifierLabel": "Identifierare",
"identifierPlaceholder": "E-post, Discord ID, etc.",
"identifierHelp": "E-postadressen, Discord ID, Telegram-användarnamn eller Signal-nummer som verifieras.",
"codeLabel": "Verifieringskod",
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.",
"verifyButton": "Verifiera"
}
}
+130 -8
View File
@@ -164,7 +164,7 @@
"changeEmailButton": "更改邮箱",
"requesting": "请求中...",
"verificationCode": "验证码",
"verificationCodePlaceholder": "输入邮件中的验证码",
"verificationCodePlaceholder": "输入验证码",
"confirmEmailChange": "确认更改邮箱",
"updating": "更新中...",
"changeHandle": "更改用户名",
@@ -202,14 +202,14 @@
"deleteAccount": "删除账户",
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
"requestDeletion": "请求删除账户",
"confirmationCode": "确认码(来自邮件)",
"confirmationCode": "确认码",
"confirmationCodePlaceholder": "输入确认码",
"yourPassword": "您的密码",
"yourPasswordPlaceholder": "输入您的密码",
"permanentlyDelete": "永久删除账户",
"deleting": "删除中...",
"messages": {
"emailCodeSent": "验证码已发送到您当前的邮箱",
"emailCodeSent": "验证码已发送到您的通知渠道",
"emailUpdated": "邮箱更新成功",
"handleUpdated": "用户名更新成功",
"passwordChanged": "密码更改成功",
@@ -451,6 +451,28 @@
},
"admin": {
"title": "管理后台",
"loading": "加载中...",
"serverConfig": "服务器配置",
"serverName": "服务器名称",
"serverNamePlaceholder": "我的 PDS",
"serverNameHelp": "显示在浏览器标签和其他地方",
"serverLogo": "服务器图标",
"logoPreview": "图标预览",
"removeLogo": "移除",
"logoHelp": "用作网站图标和导航栏显示",
"themeColors": "主题颜色",
"themeColorsHint": "留空使用默认颜色。",
"primaryLight": "主色(浅色模式)",
"primaryLightDefault": "#2c00ff(默认)",
"primaryDark": "主色(深色模式)",
"primaryDarkDefault": "#7b6bff(默认)",
"secondaryLight": "副色(浅色模式)",
"secondaryLightDefault": "#ff2400(默认)",
"secondaryDark": "副色(深色模式)",
"secondaryDarkDefault": "#ff6b5b(默认)",
"configSaved": "服务器配置已保存",
"saving": "保存中...",
"saveConfig": "保存配置",
"serverStats": "服务器统计",
"users": "用户",
"repos": "仓库",
@@ -580,20 +602,34 @@
"verify": {
"title": "验证账户",
"subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。",
"codePlaceholder": "输入6位验证码",
"tokenSubtitle": "输入验证码和接收验证码的标识符。",
"tokenTitle": "验证",
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
"codeLabel": "验证码",
"codeHelp": "复制消息中的完整验证码,包括横线",
"verifyButton": "验证账户",
"verify": "验证",
"verifying": "验证中...",
"pleaseWait": "请稍候...",
"resendCode": "重新发送验证码",
"resending": "发送中...",
"sending": "发送中...",
"codeResent": "验证码已重新发送!",
"codeResentDetail": "验证码已发送!请查收。",
"backToLogin": "返回登录",
"verifyingAccount": "正在验证账户:@{handle}",
"startOver": "使用其他账户重新开始",
"noPending": "未找到待验证的账户",
"noPendingInfo": "如果您最近创建了账户需要验证,可能需要重新创建账户。如果您已完成验证,可以直接登录。",
"createAccount": "创建账户",
"signIn": "登录"
"signIn": "登录",
"verified": "验证成功!",
"channelVerified": "您的{channel}已成功验证。",
"canNowSignIn": "您现在可以登录账户。",
"continue": "继续",
"identifierLabel": "邮箱或标识符",
"identifierPlaceholder": "you@example.com",
"identifierHelp": "接收验证码的邮箱地址或标识符"
},
"resetPassword": {
"title": "重置密码",
@@ -605,7 +641,7 @@
"sendCode": "发送重置验证码",
"sending": "发送中...",
"codeSent": "重置验证码已发送!请检查您的首选通知渠道。",
"enterCode": "输入邮件中的验证码和新密码。",
"enterCode": "输入您收到的验证码和新密码。",
"code": "重置验证码",
"codePlaceholder": "输入重置验证码",
"newPassword": "新密码",
@@ -664,20 +700,86 @@
},
"registerPasskey": {
"title": "创建通行密钥账户",
"subtitle": "使用通行密钥创建无密码账户。",
"subtitle": "使用通行密钥创建超安全账户,无需密码。",
"subtitleKeyChoice": "选择如何设置您的外部 did:web 身份。",
"subtitleInitialDidDoc": "上传您的 DID 文档以继续。",
"subtitleCreating": "正在创建您的账户...",
"subtitlePasskey": "注册通行密钥以保护您的账户。",
"subtitleAppPassword": "保存您的应用专用密码以使用第三方应用。",
"subtitleVerify": "验证您的{channel}以继续。",
"subtitleUpdatedDidDoc": "使用 PDS 签名密钥更新您的 DID 文档。",
"subtitleActivating": "正在激活您的账户...",
"subtitleComplete": "您的账户已成功创建!",
"handle": "用户名",
"handlePlaceholder": "您的用户名",
"handleHint": "您的完整用户名将是:@{handle}",
"handleDotWarning": "可以在创建账户后设置自定义域名。",
"email": "邮箱地址",
"emailPlaceholder": "you@example.com",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入您的邀请码",
"createButton": "创建账户",
"creating": "创建中...",
"continue": "继续",
"back": "返回",
"alreadyHaveAccount": "已有账户?",
"signIn": "立即登录",
"wantPassword": "想使用密码?",
"createPasswordAccount": "创建密码账户"
"createPasswordAccount": "创建密码账户",
"wantTraditional": "想使用传统密码?",
"registerWithPassword": "使用密码注册",
"contactMethod": "联系方式",
"contactMethodHint": "选择您希望如何验证账户和接收通知。",
"verificationMethod": "验证方式",
"identityType": "身份类型",
"identityTypeHint": "选择如何管理您的去中心化身份。",
"didPlcRecommended": "did:plc(推荐)",
"didPlcHint": "由 PLC 目录管理的可迁移身份",
"didWeb": "did:web",
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
"didWebBYOD": "did:web(自带域名)",
"didWebBYODHint": "使用您自己的域名",
"didWebWarningTitle": "重要:了解利弊",
"didWebWarning1": "永久绑定此 PDS",
"didWebWarning2": "无法恢复:",
"didWebWarning2Detail": "与 did:plc 不同,did:web 没有密钥轮换机制。",
"didWebWarning3": "我们的承诺:",
"didWebWarning3Detail": "如果您迁移到其他 PDS,我们将继续提供最小 DID 文档。",
"didWebWarning4": "建议:",
"didWebWarning4Detail": "除非有特定原因,否则请选择 did:plc。",
"externalDid": "您的 did:web",
"externalDidPlaceholder": "did:web:yourdomain.com",
"externalDidHint": "您需要在以下地址提供 DID 文档",
"whyPasskeyOnly": "为什么选择仅通行密钥?",
"whyPasskeyOnlyDesc": "通行密钥账户比密码账户更安全,因为它们:",
"whyPasskeyBullet1": "无法被钓鱼或在数据泄露中被盗",
"whyPasskeyBullet2": "使用硬件支持的加密密钥",
"whyPasskeyBullet3": "需要您的生物识别或设备 PIN 才能使用",
"passkeyNameLabel": "通行密钥名称(可选)",
"passkeyNamePlaceholder": "如 MacBook Touch ID",
"passkeyNameHint": "用于识别此通行密钥的友好名称",
"passkeyPrompt": "点击下方按钮创建通行密钥。系统会提示您使用:",
"passkeyPromptBullet1": "Touch ID 或 Face ID",
"passkeyPromptBullet2": "设备 PIN 或密码",
"passkeyPromptBullet3": "安全密钥(如果有的话)",
"createPasskey": "创建通行密钥",
"creatingPasskey": "正在创建通行密钥...",
"redirecting": "正在跳转到控制台...",
"loading": "加载中...",
"errors": {
"handleRequired": "请输入用户名",
"handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。",
"inviteRequired": "请输入邀请码",
"externalDidRequired": "请输入您的 did:web",
"externalDidFormat": "DID 必须以 did:web: 开头",
"emailRequired": "使用邮箱验证需要填写邮箱地址",
"discordRequired": "使用 Discord 验证需要填写 Discord ID",
"telegramRequired": "使用 Telegram 验证需要填写用户名",
"signalRequired": "使用 Signal 验证需要填写电话号码",
"passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。",
"passkeyCancelled": "通行密钥创建已取消",
"passkeyFailed": "通行密钥注册失败"
}
},
"trustedDevices": {
"title": "受信任设备",
@@ -710,5 +812,25 @@
"verify": "验证",
"verifying": "验证中...",
"cancel": "取消"
},
"verifyChannel": {
"title": "验证通道",
"subtitle": "输入发送到您通知通道的验证码。",
"signInRequired": "需要登录",
"signInRequiredDesc": "您必须登录才能验证通道。",
"signIn": "登录",
"verifying": "验证中...",
"pleaseWait": "请稍候,正在验证您的通道。",
"successTitle": "验证成功!",
"successDesc": "您的 {channel} 已成功验证。",
"backToSettings": "返回设置",
"channelLabel": "通道",
"selectChannel": "选择通道...",
"identifierLabel": "标识符",
"identifierPlaceholder": "邮箱、Discord ID 等",
"identifierHelp": "正在验证的邮箱地址、Discord ID、Telegram 用户名或 Signal 号码。",
"codeLabel": "验证码",
"codeHelp": "复制消息中的完整验证码,包括横线。",
"verifyButton": "验证"
}
}
+71 -71
View File
@@ -302,38 +302,38 @@
{#if auth.session?.isAdmin}
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>Admin Panel</h1>
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('admin.title')}</h1>
</header>
{#if loading}
<p class="loading">Loading...</p>
<p class="loading">{$_('admin.loading')}</p>
{:else}
{#if error}
<div class="message error">{error}</div>
{/if}
<section>
<h2>Server Configuration</h2>
<h2>{$_('admin.serverConfig')}</h2>
<form class="config-form" onsubmit={saveServerConfig}>
<div class="form-group">
<label for="serverName">Server Name</label>
<label for="serverName">{$_('admin.serverName')}</label>
<input
type="text"
id="serverName"
bind:value={serverNameInput}
placeholder="My PDS"
placeholder={$_('admin.serverNamePlaceholder')}
maxlength="100"
disabled={serverConfigLoading}
/>
<span class="help-text">Displayed in the browser tab and other places</span>
<span class="help-text">{$_('admin.serverNameHelp')}</span>
</div>
<div class="form-group">
<label for="serverLogo">Server Logo</label>
<label for="serverLogo">{$_('admin.serverLogo')}</label>
<div class="logo-upload">
{#if logoPreview}
<div class="logo-preview">
<img src={logoPreview} alt="Logo preview" />
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button>
<img src={logoPreview} alt={$_('admin.logoPreview')} />
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>{$_('admin.removeLogo')}</button>
</div>
{:else}
<input
@@ -345,15 +345,15 @@
/>
{/if}
</div>
<span class="help-text">Used as favicon and shown in the navbar</span>
<span class="help-text">{$_('admin.logoHelp')}</span>
</div>
<h3 class="subsection-title">Theme Colors</h3>
<p class="theme-hint">Leave blank to use default colors.</p>
<h3 class="subsection-title">{$_('admin.themeColors')}</h3>
<p class="theme-hint">{$_('admin.themeColorsHint')}</p>
<div class="color-grid">
<div class="color-group">
<label for="primaryColor">Primary (Light Mode)</label>
<label for="primaryColor">{$_('admin.primaryLight')}</label>
<div class="color-input-row">
<input
type="color"
@@ -364,13 +364,13 @@
type="text"
id="primaryColor"
bind:value={primaryColorInput}
placeholder="#2c00ff (default)"
placeholder={$_('admin.primaryLightDefault')}
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="primaryColorDark">Primary (Dark Mode)</label>
<label for="primaryColorDark">{$_('admin.primaryDark')}</label>
<div class="color-input-row">
<input
type="color"
@@ -381,13 +381,13 @@
type="text"
id="primaryColorDark"
bind:value={primaryColorDarkInput}
placeholder="#7b6bff (default)"
placeholder={$_('admin.primaryDarkDefault')}
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="secondaryColor">Secondary (Light Mode)</label>
<label for="secondaryColor">{$_('admin.secondaryLight')}</label>
<div class="color-input-row">
<input
type="color"
@@ -398,13 +398,13 @@
type="text"
id="secondaryColor"
bind:value={secondaryColorInput}
placeholder="#ff2400 (default)"
placeholder={$_('admin.secondaryLightDefault')}
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
<label for="secondaryColorDark">{$_('admin.secondaryDark')}</label>
<div class="color-input-row">
<input
type="color"
@@ -415,7 +415,7 @@
type="text"
id="secondaryColorDark"
bind:value={secondaryColorDarkInput}
placeholder="#ff6b5b (default)"
placeholder={$_('admin.secondaryDarkDefault')}
disabled={serverConfigLoading}
/>
</div>
@@ -426,48 +426,48 @@
<div class="message error">{serverConfigError}</div>
{/if}
{#if serverConfigSuccess}
<div class="message success">Server configuration saved</div>
<div class="message success">{$_('admin.configSaved')}</div>
{/if}
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
{serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')}
</button>
</form>
</section>
{#if stats}
<section>
<h2>Server Statistics</h2>
<h2>{$_('admin.serverStats')}</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{formatNumber(stats.userCount)}</div>
<div class="stat-label">Users</div>
<div class="stat-label">{$_('admin.users')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
<div class="stat-label">Repositories</div>
<div class="stat-label">{$_('admin.repos')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
<div class="stat-label">Records</div>
<div class="stat-label">{$_('admin.records')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
<div class="stat-label">Blob Storage</div>
<div class="stat-label">{$_('admin.blobStorage')}</div>
</div>
</div>
<button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
<button class="refresh-btn" onclick={loadStats}>{$_('admin.refreshStats')}</button>
</section>
{/if}
<section>
<h2>User Management</h2>
<h2>{$_('admin.userManagement')}</h2>
<form class="search-form" onsubmit={handleSearch}>
<input
type="text"
bind:value={handleSearchQuery}
placeholder="Search by handle (optional)"
placeholder={$_('admin.searchPlaceholder')}
disabled={usersLoading}
/>
<button type="submit" disabled={usersLoading}>
{usersLoading ? 'Loading...' : 'Search Users'}
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
</button>
</form>
{#if usersError}
@@ -476,15 +476,15 @@
{#if showUsers}
<div class="user-list">
{#if users.length === 0}
<p class="no-results">No users found</p>
<p class="no-results">{$_('admin.noUsers')}</p>
{:else}
<table>
<thead>
<tr>
<th>Handle</th>
<th>Email</th>
<th>Status</th>
<th>Created</th>
<th>{$_('admin.handle')}</th>
<th>{$_('admin.email')}</th>
<th>{$_('admin.status')}</th>
<th>{$_('admin.created')}</th>
</tr>
</thead>
<tbody>
@@ -494,11 +494,11 @@
<td class="email">{user.email || '-'}</td>
<td>
{#if user.deactivatedAt}
<span class="badge deactivated">Deactivated</span>
<span class="badge deactivated">{$_('admin.deactivated')}</span>
{:else if user.emailConfirmedAt}
<span class="badge verified">Verified</span>
<span class="badge verified">{$_('admin.verified')}</span>
{:else}
<span class="badge unverified">Unverified</span>
<span class="badge unverified">{$_('admin.unverified')}</span>
{/if}
</td>
<td class="date">{formatDate(user.indexedAt)}</td>
@@ -508,7 +508,7 @@
</table>
{#if usersCursor}
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
{usersLoading ? 'Loading...' : 'Load More'}
{usersLoading ? $_('admin.loading') : $_('admin.loadMore')}
</button>
{/if}
{/if}
@@ -516,10 +516,10 @@
{/if}
</section>
<section>
<h2>Invite Codes</h2>
<h2>{$_('admin.inviteCodes')}</h2>
<div class="section-actions">
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
{invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
</button>
</div>
{#if invitesError}
@@ -528,17 +528,17 @@
{#if showInvites}
<div class="invite-list">
{#if invites.length === 0}
<p class="no-results">No invite codes found</p>
<p class="no-results">{$_('admin.noInvites')}</p>
{:else}
<table>
<thead>
<tr>
<th>Code</th>
<th>Available</th>
<th>Uses</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th>{$_('admin.code')}</th>
<th>{$_('admin.available')}</th>
<th>{$_('admin.uses')}</th>
<th>{$_('admin.status')}</th>
<th>{$_('admin.created')}</th>
<th>{$_('admin.actions')}</th>
</tr>
</thead>
<tbody>
@@ -549,18 +549,18 @@
<td>{invite.uses.length}</td>
<td>
{#if invite.disabled}
<span class="badge deactivated">Disabled</span>
<span class="badge deactivated">{$_('admin.disabled')}</span>
{:else if invite.available === 0}
<span class="badge unverified">Exhausted</span>
<span class="badge unverified">{$_('admin.exhausted')}</span>
{:else}
<span class="badge verified">Active</span>
<span class="badge verified">{$_('admin.active')}</span>
{/if}
</td>
<td class="date">{formatDate(invite.createdAt)}</td>
<td>
{#if !invite.disabled}
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
Disable
{$_('admin.disable')}
</button>
{:else}
<span class="muted">-</span>
@@ -572,7 +572,7 @@
</table>
{#if invitesCursor}
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
{invitesLoading ? 'Loading...' : 'Load More'}
{invitesLoading ? $_('admin.loading') : $_('admin.loadMore')}
</button>
{/if}
{/if}
@@ -585,38 +585,38 @@
<div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2>User Details</h2>
<h2>{$_('admin.userDetails')}</h2>
<button class="close-btn" onclick={closeUserDetail}>&times;</button>
</div>
{#if userDetailLoading}
<p class="loading">Loading...</p>
<p class="loading">{$_('admin.loading')}</p>
{:else}
<div class="modal-body">
<dl class="user-details">
<dt>Handle</dt>
<dt>{$_('admin.handle')}</dt>
<dd>@{selectedUser.handle}</dd>
<dt>DID</dt>
<dt>{$_('admin.did')}</dt>
<dd class="mono">{selectedUser.did}</dd>
<dt>Email</dt>
<dt>{$_('admin.email')}</dt>
<dd>{selectedUser.email || '-'}</dd>
<dt>Status</dt>
<dt>{$_('admin.status')}</dt>
<dd>
{#if selectedUser.deactivatedAt}
<span class="badge deactivated">Deactivated</span>
<span class="badge deactivated">{$_('admin.deactivated')}</span>
{:else if selectedUser.emailConfirmedAt}
<span class="badge verified">Verified</span>
<span class="badge verified">{$_('admin.verified')}</span>
{:else}
<span class="badge unverified">Unverified</span>
<span class="badge unverified">{$_('admin.unverified')}</span>
{/if}
</dd>
<dt>Created</dt>
<dt>{$_('admin.created')}</dt>
<dd>{formatDateTime(selectedUser.indexedAt)}</dd>
<dt>Invites</dt>
<dt>{$_('admin.invites')}</dt>
<dd>
{#if selectedUser.invitesDisabled}
<span class="badge deactivated">Disabled</span>
<span class="badge deactivated">{$_('admin.disabled')}</span>
{:else}
<span class="badge verified">Enabled</span>
<span class="badge verified">{$_('admin.enabled')}</span>
{/if}
</dd>
</dl>
@@ -626,14 +626,14 @@
onclick={toggleUserInvites}
disabled={userActionLoading}
>
{selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
</button>
<button
class="action-btn danger"
onclick={deleteUser}
disabled={userActionLoading}
>
Delete Account
{$_('admin.deleteAccount')}
</button>
</div>
</div>
@@ -642,7 +642,7 @@
</div>
{/if}
{:else if auth.loading}
<div class="loading">Loading...</div>
<div class="loading">{$_('admin.loading')}</div>
{/if}
<style>
.page {
+10 -1
View File
@@ -93,8 +93,17 @@
if (!auth.session || !verificationCode) return
verificationError = null
verificationSuccess = null
let identifier = ''
switch (channel) {
case 'discord': identifier = discordId; break
case 'telegram': identifier = telegramUsername; break
case 'signal': identifier = signalNumber; break
}
if (!identifier) return
try {
await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
await refreshSession()
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
verificationCode = ''
+10 -4
View File
@@ -33,6 +33,14 @@
}
})
let creatingStarted = false
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasswordAccount()
}
})
async function loadServerInfo() {
try {
serverInfo = await api.describeServer()
@@ -140,7 +148,7 @@
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
case 'activating': return 'Activating your account...'
case 'complete': return 'Your account has been created successfully!'
case 'redirect-to-dashboard': return 'Your account has been created successfully!'
default: return ''
}
}
@@ -383,9 +391,7 @@
/>
{:else if flow.state.step === 'creating'}
{#await flow.createPasswordAccount()}
<p class="loading">{$_('register.creating')}</p>
{/await}
<p class="loading">{$_('register.creating')}</p>
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
+96 -90
View File
@@ -34,6 +34,14 @@
}
})
let creatingStarted = false
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasskeyAccount()
}
})
async function loadServerInfo() {
try {
serverInfo = await api.describeServer()
@@ -49,27 +57,27 @@
function validateInfoStep(): string | null {
if (!flow) return 'Flow not initialized'
const info = flow.info
if (!info.handle.trim()) return 'Handle is required'
if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return 'Invite code is required'
return $_('registerPasskey.errors.inviteRequired')
}
if (info.didType === 'web-external') {
if (!info.externalDid?.trim()) return 'External did:web is required'
if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
}
switch (info.verificationChannel) {
case 'email':
if (!info.email.trim()) return 'Email is required for email verification'
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
break
}
return null
@@ -121,7 +129,7 @@
}
if (!window.PublicKeyCredential) {
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
return
}
@@ -153,7 +161,7 @@
})
if (!credential) {
flow.setError('Passkey creation was cancelled')
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
flow.setSubmitting(false)
return
}
@@ -180,13 +188,13 @@
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
flow.setError('Passkey creation was cancelled')
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
} else if (err instanceof ApiError) {
flow.setError(err.message || 'Passkey registration failed')
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
} else if (err instanceof Error) {
flow.setError(err.message || 'Passkey registration failed')
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
} else {
flow.setError('Passkey registration failed')
flow.setError($_('registerPasskey.errors.passkeyFailed'))
}
} finally {
flow.setSubmitting(false)
@@ -207,10 +215,10 @@
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'Email'
case 'discord': return 'Discord'
case 'telegram': return 'Telegram'
case 'signal': return 'Signal'
case 'email': return $_('register.email')
case 'discord': return $_('register.discord')
case 'telegram': return $_('register.telegram')
case 'signal': return $_('register.signal')
default: return ch
}
}
@@ -230,16 +238,16 @@
function getSubtitle(): string {
if (!flow) return ''
switch (flow.state.step) {
case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
case 'key-choice': return 'Choose how to set up your external did:web identity.'
case 'initial-did-doc': return 'Upload your DID document to continue.'
case 'creating': return 'Creating your account...'
case 'passkey': return 'Register your passkey to secure your account.'
case 'app-password': return 'Save your app password for third-party apps.'
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
case 'activating': return 'Activating your account...'
case 'complete': return 'Your account has been created successfully!'
case 'info': return $_('registerPasskey.subtitle')
case 'key-choice': return $_('registerPasskey.subtitleKeyChoice')
case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc')
case 'creating': return $_('registerPasskey.subtitleCreating')
case 'passkey': return $_('registerPasskey.subtitlePasskey')
case 'app-password': return $_('registerPasskey.subtitleAppPassword')
case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc')
case 'activating': return $_('registerPasskey.subtitleActivating')
case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete')
default: return ''
}
}
@@ -259,7 +267,7 @@
</div>
{/if}
<h1>Create Passkey Account</h1>
<h1>{$_('registerPasskey.title')}</h1>
<p class="subtitle">{getSubtitle()}</p>
{#if flow?.state.error}
@@ -267,140 +275,140 @@
{/if}
{#if loadingServerInfo || !flow}
<p class="loading">Loading...</p>
<p class="loading">{$_('registerPasskey.loading')}</p>
{:else if flow.state.step === 'info'}
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">Handle</label>
<label for="handle">{$_('registerPasskey.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
placeholder="yourname"
placeholder={$_('registerPasskey.handlePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">Your full handle will be: @{fullHandle()}</p>
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset class="section-fieldset">
<legend>Contact Method</legend>
<p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
<legend>{$_('registerPasskey.contactMethod')}</legend>
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
<div class="field">
<label for="verification-channel">Verification Method</label>
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">Email</option>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">Email Address</label>
<input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
<label for="email">{$_('registerPasskey.email')}</label>
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">Discord User ID</label>
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
<label for="discord-id">{$_('register.discordId')}</label>
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">Telegram Username</label>
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">Signal Phone Number</label>
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
<p class="hint">Include country code (e.g., +1 for US)</p>
<label for="signal-number">{$_('register.signalNumber')}</label>
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.signalNumberHint')}</p>
</div>
{/if}
</fieldset>
<fieldset class="section-fieldset">
<legend>Identity Type</legend>
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>did:plc</strong> (Recommended)
<span class="radio-hint">Portable identity managed by PLC Directory</span>
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>did:web</strong>
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
<strong>{$_('registerPasskey.didWeb')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
</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>did:web (BYOD)</strong>
<span class="radio-hint">Bring your own domain</span>
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>Important: Understand the trade-offs</strong>
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
<li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li>
<li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li>
<li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">Your did:web</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
<p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">Invite Code <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
</div>
{/if}
<div class="info-box">
<strong>Why passkey-only?</strong>
<p>Passkey accounts are more secure than password-based accounts because they:</p>
<strong>{$_('registerPasskey.whyPasskeyOnly')}</strong>
<p>{$_('registerPasskey.whyPasskeyOnlyDesc')}</p>
<ul>
<li>Cannot be phished or stolen in data breaches</li>
<li>Use hardware-backed cryptographic keys</li>
<li>Require your biometric or device PIN to use</li>
<li>{$_('registerPasskey.whyPasskeyBullet1')}</li>
<li>{$_('registerPasskey.whyPasskeyBullet2')}</li>
<li>{$_('registerPasskey.whyPasskeyBullet3')}</li>
</ul>
</div>
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? 'Creating account...' : 'Continue'}
{flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')}
</button>
</form>
<p class="link-text">
Want a traditional password? <a href="#/register">Register with password</a>
{$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a>
</p>
{:else if flow.state.step === 'key-choice'}
@@ -415,33 +423,31 @@
/>
{:else if flow.state.step === 'creating'}
{#await flow.createPasskeyAccount()}
<p class="loading">Creating your account...</p>
{/await}
<p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
{:else if flow.state.step === 'passkey'}
<div class="step-content">
<div class="field">
<label for="passkey-name">Passkey Name (optional)</label>
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
<p class="hint">A friendly name to identify this passkey</p>
<label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label>
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} />
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<div class="info-box">
<p>Click the button below to create your passkey. You'll be prompted to use:</p>
<p>{$_('registerPasskey.passkeyPrompt')}</p>
<ul>
<li>Touch ID or Face ID</li>
<li>Your device PIN or password</li>
<li>A security key (if you have one)</li>
<li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
<li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
<li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
</ul>
</div>
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
{flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
</button>
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
Back
{$_('registerPasskey.back')}
</button>
</div>
@@ -459,7 +465,7 @@
/>
{:else if flow.state.step === 'redirect-to-dashboard'}
<p class="loading">Redirecting to dashboard...</p>
<p class="loading">{$_('registerPasskey.redirecting')}</p>
{/if}
</div>
+1 -1
View File
@@ -55,7 +55,7 @@
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
emailTokenRequired = result.tokenRequired
if (emailTokenRequired) {
showMessage('success', $_('settings.messages.verificationCodeSent'))
showMessage('success', $_('settings.messages.emailCodeSent'))
} else {
await api.updateEmail(auth.session.accessJwt, newEmail)
await refreshSession()
+236 -33
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { navigate } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
@@ -11,30 +13,71 @@
channel: string
}
type VerificationMode = 'signup' | 'token'
let mode = $state<VerificationMode>('signup')
let pendingVerification = $state<PendingVerification | null>(null)
let verificationCode = $state('')
let identifier = $state('')
let submitting = $state(false)
let resendingCode = $state(false)
let error = $state<string | null>(null)
let resendMessage = $state<string | null>(null)
let success = $state(false)
let autoSubmitting = $state(false)
let successPurpose = $state<string | null>(null)
let successChannel = $state<string | null>(null)
const auth = getAuthState()
$effect(() => {
if (auth.session) {
clearPendingVerification()
navigate('/dashboard')
function parseQueryParams() {
const hash = window.location.hash
const queryIndex = hash.indexOf('?')
if (queryIndex === -1) return {}
const queryString = hash.slice(queryIndex + 1)
const params: Record<string, string> = {}
for (const pair of queryString.split('&')) {
const [key, value] = pair.split('=')
if (key && value) {
params[decodeURIComponent(key)] = decodeURIComponent(value)
}
}
return params
}
onMount(async () => {
const params = parseQueryParams()
if (params.token) {
mode = 'token'
verificationCode = params.token
if (params.identifier) {
identifier = params.identifier
}
if (verificationCode && identifier) {
autoSubmitting = true
await handleTokenVerification()
autoSubmitting = false
}
} else {
mode = 'signup'
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
pendingVerification = JSON.parse(stored)
} catch {
pendingVerification = null
}
}
}
})
$effect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
pendingVerification = JSON.parse(stored)
} catch {
pendingVerification = null
}
if (mode === 'signup' && auth.session) {
clearPendingVerification()
navigate('/dashboard')
}
})
@@ -43,7 +86,7 @@
pendingVerification = null
}
async function handleVerification(e: Event) {
async function handleSignupVerification(e: Event) {
e.preventDefault()
if (!pendingVerification || !verificationCode.trim()) return
@@ -61,20 +104,67 @@
}
}
async function handleResendCode() {
if (!pendingVerification || resendingCode) return
async function handleTokenVerification() {
if (!verificationCode.trim() || !identifier.trim()) return
resendingCode = true
resendMessage = null
submitting = true
error = null
try {
await resendVerification(pendingVerification.did)
resendMessage = 'Verification code resent!'
const result = await api.verifyToken(
verificationCode.trim(),
identifier.trim(),
auth.session?.accessJwt
)
success = true
successPurpose = result.purpose
successChannel = result.channel
} catch (e: any) {
error = e.message || 'Failed to resend code'
if (e instanceof ApiError) {
if (e.error === 'AuthenticationRequired') {
error = 'You must be signed in to complete this verification. Please sign in and try again.'
} else {
error = e.message
}
} else {
error = 'Verification failed'
}
} finally {
resendingCode = false
submitting = false
}
}
async function handleResendCode() {
if (mode === 'signup') {
if (!pendingVerification || resendingCode) return
resendingCode = true
resendMessage = null
error = null
try {
await resendVerification(pendingVerification.did)
resendMessage = $_('verify.codeResent')
} catch (e: any) {
error = e.message || 'Failed to resend code'
} finally {
resendingCode = false
}
} else {
if (!identifier.trim() || resendingCode) return
resendingCode = true
resendMessage = null
error = null
try {
await api.resendMigrationVerification(identifier.trim())
resendMessage = $_('verify.codeResentDetail')
} catch (e: any) {
error = e.message || 'Failed to resend verification'
} finally {
resendingCode = false
}
}
}
@@ -87,25 +177,69 @@
default: return ch
}
}
function goToNextStep() {
if (successPurpose === 'migration') {
navigate('/login')
} else if (successChannel === 'email') {
navigate('/settings')
} else {
navigate('/comms')
}
}
</script>
<div class="verify-page">
{#if error}
<div class="message error">{error}</div>
{/if}
{#if autoSubmitting}
<div class="loading-container">
<h1>{$_('verify.verifying')}</h1>
<p class="subtitle">{$_('verify.pleaseWait')}</p>
</div>
{:else if success}
<div class="success-container">
<h1>{$_('verify.verified')}</h1>
{#if successPurpose === 'migration' || successPurpose === 'signup'}
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
<p class="info-text">{$_('verify.canNowSignIn')}</p>
<div class="actions">
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
</div>
{:else}
<p class="subtitle">
{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}
</p>
<div class="actions">
<button class="btn" onclick={goToNextStep}>{$_('verify.continue')}</button>
</div>
{/if}
</div>
{:else if mode === 'token'}
<h1>{$_('verify.tokenTitle')}</h1>
<p class="subtitle">{$_('verify.tokenSubtitle')}</p>
{#if pendingVerification}
<h1>{$_('verify.title')}</h1>
<p class="subtitle">
{$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
</p>
<p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
{#if error}
<div class="message error">{error}</div>
{/if}
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
<form onsubmit={(e) => { e.preventDefault(); handleTokenVerification(); }}>
<div class="field">
<label for="identifier">{$_('verify.identifierLabel')}</label>
<input
id="identifier"
type="text"
bind:value={identifier}
placeholder={$_('verify.identifierPlaceholder')}
disabled={submitting}
required
autocomplete="email"
/>
<p class="field-help">{$_('verify.identifierHelp')}</p>
</div>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
@@ -115,10 +249,53 @@
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
maxlength="6"
inputmode="numeric"
autocomplete="one-time-code"
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.codeHelp')}</p>
</div>
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
{submitting ? $_('verify.verifying') : $_('verify.verify')}
</button>
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
{resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
</button>
</form>
<p class="link-text">
<a href="#/login">{$_('verify.backToLogin')}</a>
</p>
{:else if pendingVerification}
<h1>{$_('verify.title')}</h1>
<p class="subtitle">
{$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
</p>
<p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
{#if error}
<div class="message error">{error}</div>
{/if}
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.codeHelp')}</p>
</div>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
@@ -178,6 +355,17 @@
gap: var(--space-4);
}
.field-help {
font-size: var(--text-xs);
color: var(--text-secondary);
margin: var(--space-1) 0 0 0;
}
.token-input {
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.link-text {
text-align: center;
margin-top: var(--space-6);
@@ -223,4 +411,19 @@
background: var(--accent);
color: var(--text-inverse);
}
.success-container,
.loading-container {
text-align: center;
}
.success-container .actions {
justify-content: center;
margin-top: var(--space-6);
}
.success-container .btn {
flex: none;
padding: var(--space-4) var(--space-8);
}
</style>
@@ -0,0 +1 @@
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'migration_verification';
@@ -0,0 +1 @@
DROP TABLE IF EXISTS channel_verifications;
+34 -28
View File
@@ -1,7 +1,7 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::error;
@@ -80,7 +80,7 @@ pub async fn get_server_config(
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()",
)
.bind(key)
.bind(value)
@@ -105,7 +105,9 @@ pub async fn update_server_config(
if let Some(server_name) = req.server_name {
let trimmed = server_name.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
return Err(ApiError::InvalidRequest(
"Server name must be 1-100 characters".into(),
));
}
upsert_config(&state.db, "server_name", trimmed).await?;
}
@@ -116,7 +118,9 @@ pub async fn update_server_config(
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
return Err(ApiError::InvalidRequest(
"Invalid primary color format (expected #RRGGBB)".into(),
));
}
}
@@ -126,7 +130,9 @@ pub async fn update_server_config(
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
return Err(ApiError::InvalidRequest(
"Invalid primary dark color format (expected #RRGGBB)".into(),
));
}
}
@@ -136,7 +142,9 @@ pub async fn update_server_config(
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
return Err(ApiError::InvalidRequest(
"Invalid secondary color format (expected #RRGGBB)".into(),
));
}
}
@@ -146,16 +154,17 @@ pub async fn update_server_config(
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
return Err(ApiError::InvalidRequest(
"Invalid secondary dark color format (expected #RRGGBB)".into(),
));
}
}
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid: Option<String> = sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await?;
let old_logo_cid: Option<String> =
sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
.fetch_optional(&state.db)
.await?;
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
(Some(old), true) => Some(old.clone()),
@@ -163,23 +172,20 @@ pub async fn update_server_config(
_ => None,
};
if let Some(old_cid) = should_delete_old {
if let Ok(Some(blob)) = sqlx::query!(
"SELECT storage_key FROM blobs WHERE cid = $1",
old_cid
)
.fetch_optional(&state.db)
.await
{
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
.execute(&state.db)
if let Some(old_cid) = should_delete_old
&& let Ok(Some(blob)) =
sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid)
.fetch_optional(&state.db)
.await
{
error!("Failed to delete old logo blob record: {:?}", e);
}
{
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
.execute(&state.db)
.await
{
error!("Failed to delete old logo blob record: {:?}", e);
}
}
+3 -1
View File
@@ -94,7 +94,9 @@ impl ApiError {
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
Cow::Borrowed("UpstreamFailure")
}
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
Self::UpstreamError { error, .. } => {
if let Some(e) = error {
+75 -71
View File
@@ -132,11 +132,11 @@ pub async fn create_account(
.map(|d| d.starts_with("did:plc:"))
.unwrap_or(false);
if is_migration || is_did_web_byod {
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
{
if provided_did != auth_did {
return (
if (is_migration || is_did_web_byod)
&& let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
{
if provided_did != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
@@ -144,12 +144,11 @@ pub async fn create_account(
})),
)
.into_response();
}
if is_did_web_byod {
info!(did = %provided_did, "Processing did:web BYOD account creation");
} else {
info!(did = %provided_did, "Processing account migration");
}
}
if is_did_web_byod {
info!(did = %provided_did, "Processing did:web BYOD account creation");
} else {
info!(did = %provided_did, "Processing account migration");
}
}
@@ -348,16 +347,15 @@ pub async fn create_account(
)
.into_response();
}
if !is_did_web_byod {
if let Err(e) =
if !is_did_web_byod
&& let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
info!(did = %d, "Creating external did:web account");
d.clone()
@@ -368,17 +366,20 @@ pub async fn create_account(
info!(did = %d, "Migration with existing did:plc");
d.clone()
} else if d.starts_with("did:web:") {
if !is_did_web_byod {
if let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
if !is_did_web_byod
&& let Err(e) = verify_did_web(
d,
&hostname,
&input.handle,
input.signing_key.as_deref(),
)
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
d.clone()
} else if !d.trim().is_empty() {
@@ -710,8 +711,6 @@ pub async fn create_account(
.into_response();
}
};
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut *tx)
.await
@@ -758,7 +757,7 @@ pub async fn create_account(
)
.bind(is_first_user)
.bind(deactivated_at)
.bind(is_migration)
.bind(false)
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
@@ -806,25 +805,6 @@ pub async fn create_account(
}
};
if !is_migration
&& let Some(ref recipient) = verification_recipient
&& let Err(e) = sqlx::query!(
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
user_id,
verification_channel as _,
verification_code,
recipient,
code_expires_at
)
.execute(&mut *tx)
.await {
error!("Error inserting verification code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(enc) => enc,
Err(e) => {
@@ -881,17 +861,18 @@ pub async fn create_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &signing_key) {
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let (commit_bytes, _sig) =
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) {
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let commit_cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
@@ -973,22 +954,45 @@ pub async fn create_account(
warn!("Failed to create default profile for {}: {}", did, e);
}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if !is_migration {
if let Some(ref recipient) = verification_recipient
&& let Err(e) = crate::comms::enqueue_signup_verification(
if let Some(ref recipient) = verification_recipient {
let verification_token = crate::auth::verification_token::generate_signup_token(
&did,
verification_channel,
recipient,
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
user_id,
verification_channel,
recipient,
&verification_code,
&formatted_token,
None,
)
.await
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
}
}
} else if let Some(ref user_email) = email {
let token = crate::auth::verification_token::generate_migration_token(&did, user_email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if let Err(e) = crate::comms::enqueue_migration_verification(
&state.db,
user_id,
user_email,
&formatted_token,
&hostname,
)
.await
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
warn!("Failed to enqueue migration verification email: {:?}", e);
}
}
+33 -59
View File
@@ -6,21 +6,11 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::Row;
use tracing::info;
fn generate_verification_code() -> String {
rand::thread_rng()
.sample_iter(&rand::distributions::Uniform::new(0, 10))
.take(6)
.map(|x| x.to_string())
.collect()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPrefsResponse {
@@ -228,36 +218,28 @@ pub struct UpdateNotificationPrefsResponse {
pub async fn request_channel_verification(
db: &sqlx::PgPool,
user_id: uuid::Uuid,
did: &str,
channel: &str,
identifier: &str,
handle: Option<&str>,
) -> Result<String, String> {
let code = generate_verification_code();
let expires_at = Utc::now() + Duration::minutes(10);
sqlx::query!(
r#"
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
VALUES ($1, $2::comms_channel, $3, $4, $5)
ON CONFLICT (user_id, channel) DO UPDATE
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
"#,
user_id,
channel as _,
code,
identifier,
expires_at
)
.execute(db)
.await
.map_err(|e| format!("Database error: {}", e))?;
let token =
crate::auth::verification_token::generate_channel_update_token(did, channel, identifier);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if channel == "email" {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle_str = handle.unwrap_or("user");
crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
.await
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
crate::comms::enqueue_email_update(
db,
user_id,
identifier,
handle_str,
&formatted_token,
&hostname,
)
.await
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
} else {
sqlx::query!(
r#"
@@ -267,15 +249,15 @@ pub async fn request_channel_verification(
user_id,
channel as _,
identifier,
format!("Your verification code is: {}", code),
json!({"code": code})
format!("Your verification code is: {}", formatted_token),
json!({"code": formatted_token})
)
.execute(db)
.await
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
}
Ok(code)
Ok(token)
}
pub async fn update_notification_prefs(
@@ -397,6 +379,7 @@ pub async fn update_notification_prefs(
if let Err(e) = request_channel_verification(
&state.db,
user_id,
&user.did,
"email",
&email_clean,
Some(&handle),
@@ -429,16 +412,12 @@ pub async fn update_notification_prefs(
)
.into_response();
}
let _ = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
user_id
)
.execute(&state.db)
.await;
info!(did = %user.did, "Cleared Discord ID");
} else {
if let Err(e) =
request_channel_verification(&state.db, user_id, "discord", discord_id, None).await
if let Err(e) = request_channel_verification(
&state.db, user_id, &user.did, "discord", discord_id, None,
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -467,17 +446,17 @@ pub async fn update_notification_prefs(
)
.into_response();
}
let _ = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
user_id
)
.execute(&state.db)
.await;
info!(did = %user.did, "Cleared Telegram username");
} else {
if let Err(e) =
request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None)
.await
if let Err(e) = request_channel_verification(
&state.db,
user_id,
&user.did,
"telegram",
telegram_clean,
None,
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -505,16 +484,11 @@ pub async fn update_notification_prefs(
)
.into_response();
}
let _ = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
user_id
)
.execute(&state.db)
.await;
info!(did = %user.did, "Cleared Signal number");
} else {
if let Err(e) =
request_channel_verification(&state.db, user_id, "signal", signal, None).await
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
+1 -1
View File
@@ -3,7 +3,7 @@ use bytes::Bytes;
use cid::Cid;
use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
+68 -112
View File
@@ -6,7 +6,6 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
@@ -66,7 +65,7 @@ pub async fn request_email_update(
return e;
}
let did = auth_user.did;
let did = auth_user.did.clone();
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -117,6 +116,7 @@ pub async fn request_email_update(
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
&state.db,
user_id,
&did,
"email",
&email,
Some(&handle),
@@ -206,62 +206,50 @@ pub async fn confirm_email(
}
};
let verification = match sqlx::query!(
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
user_id
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
let email = input.email.trim().to_lowercase();
let confirmation_code =
crate::auth::verification_token::normalize_token_input(input.token.trim());
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_code,
"email",
&email,
);
match verified {
Ok(token_data) => {
if token_data.did != did {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
)
.into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
};
let pending_email = verification.pending_identifier.unwrap_or_default();
let email = input.email.trim().to_lowercase();
let confirmation_code = input.token.trim();
if pending_email != email {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
)
.into_response();
}
if verification.code != confirmation_code {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
if Utc::now() > verification.expires_at {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => return ApiError::InternalError.into_response(),
};
let update = sqlx::query!(
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
pending_email,
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
email,
user_id
)
.execute(&mut *tx)
.execute(&state.db)
.await;
if let Err(e) = update {
@@ -283,21 +271,6 @@ pub async fn confirm_email(
.into_response();
}
if let Err(e) = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
user_id
)
.execute(&mut *tx)
.await
{
error!("Failed to delete verification record: {:?}", e);
return ApiError::InternalError.into_response();
}
if tx.commit().await.is_err() {
return ApiError::InternalError.into_response();
}
info!("Email updated for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
}
@@ -377,50 +350,49 @@ pub async fn update_email(
return (StatusCode::OK, Json(json!({}))).into_response();
}
let verification = sqlx::query!(
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
user_id
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let confirmation_token = match &input.token {
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
)
.into_response();
}
};
if let Some(ver) = verification {
let confirmation_token = match &input.token {
Some(t) => t.trim(),
None => {
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email",
&new_email,
);
match verified {
Ok(token_data) => {
if token_data.did != did {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
)
.into_response();
}
};
let pending_email = ver.pending_identifier.unwrap_or_default();
if pending_email.to_lowercase() != new_email {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
)
.into_response();
}
if ver.code != confirmation_token {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
if Utc::now() > ver.expires_at {
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
}
let exists = sqlx::query!(
@@ -439,17 +411,12 @@ pub async fn update_email(
.into_response();
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => return ApiError::InternalError.into_response(),
};
let update = sqlx::query!(
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
new_email,
user_id
)
.execute(&mut *tx)
.execute(&state.db)
.await;
if let Err(e) = update {
@@ -471,17 +438,6 @@ pub async fn update_email(
.into_response();
}
let _ = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
user_id
)
.execute(&mut *tx)
.await;
if tx.commit().await.is_err() {
return ApiError::InternalError.into_response();
}
match sqlx::query!(
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
user_id,
+11 -12
View File
@@ -9,18 +9,17 @@ use axum::{
use tracing::error;
pub async fn get_logo(State(state): State<AppState>) -> Response {
let logo_cid: Option<String> = match sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await
{
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let logo_cid: Option<String> =
match sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
.fetch_optional(&state.db)
.await
{
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let cid = match logo_cid {
Some(c) if !c.is_empty() => c,
+7 -3
View File
@@ -13,6 +13,8 @@ pub mod session;
pub mod signing_key;
pub mod totp;
pub mod trusted_devices;
pub mod verify_email;
pub mod verify_token;
pub use account_status::{
activate_account, check_account_status, deactivate_account, delete_account,
@@ -35,9 +37,9 @@ pub use password::{
change_password, get_password_status, remove_password, request_password_reset, reset_password,
};
pub use reauth::{
check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response,
reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response,
reauth_totp, update_mfa_verified,
check_legacy_session_mfa, check_reauth_required, get_reauth_status,
legacy_mfa_required_response, reauth_passkey_finish, reauth_passkey_start, reauth_password,
reauth_required_response, reauth_totp, update_mfa_verified,
};
pub use service_auth::get_service_auth;
pub use session::{
@@ -54,3 +56,5 @@ pub use trusted_devices::{
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
trust_device, update_trusted_device,
};
pub use verify_email::{resend_migration_verification, verify_migration_email};
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
+36 -51
View File
@@ -117,7 +117,10 @@ pub async fn create_passkey_account(
.await
{
Ok(claims) => {
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
debug!(
"Service token verified for BYOD did:web: iss={}",
claims.iss
);
Some(claims.iss)
}
Err(e) => {
@@ -342,9 +345,10 @@ pub async fn create_passkey_account(
.into_response();
}
if is_byod_did_web {
if let Some(ref auth_did) = byod_auth {
if d != auth_did {
return (
if let Some(ref auth_did) = byod_auth
&& d != auth_did
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
@@ -352,7 +356,6 @@ pub async fn create_passkey_account(
})),
)
.into_response();
}
}
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
} else {
@@ -416,12 +419,6 @@ pub async fn create_passkey_account(
info!(did = %did, handle = %handle, "Created DID for passkey-only account");
let verification_code = format!(
"{:06}",
rand::Rng::gen_range(&mut rand::thread_rng(), 0..1_000_000u32)
);
let verification_code_expires_at = Utc::now() + Duration::minutes(30);
let setup_token = generate_setup_token();
let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
Ok(h) => h,
@@ -591,17 +588,18 @@ pub async fn create_passkey_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &secret_key) {
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let (commit_bytes, _sig) =
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) {
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
@@ -647,25 +645,6 @@ pub async fn create_passkey_account(
.await;
}
if let Err(e) = sqlx::query!(
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
user_id,
verification_channel as _,
verification_code,
verification_recipient,
verification_code_expires_at
)
.execute(&mut *tx)
.await
{
error!("Error inserting channel verification: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
@@ -703,12 +682,19 @@ pub async fn create_passkey_account(
}
}
let verification_token = crate::auth::verification_token::generate_signup_token(
&did,
verification_channel,
&verification_recipient,
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
user_id,
verification_channel,
&verification_recipient,
&verification_code,
&formatted_token,
None,
)
.await
@@ -847,21 +833,20 @@ pub async fn complete_passkey_setup(
}
};
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value(
input.passkey_credential,
) {
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
match serde_json::from_value(input.passkey_credential) {
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
),
)
.into_response();
}
};
}
};
let security_key = match webauthn.finish_registration(&credential, &reg_state) {
Ok(sk) => sk,
+7 -1
View File
@@ -471,7 +471,13 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
.await;
}
if crate::api::server::reauth::check_reauth_required_cached(&state.db, &state.cache, &auth.0.did).await {
if crate::api::server::reauth::check_reauth_required_cached(
&state.db,
&state.cache,
&auth.0.did,
)
.await
{
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
+10 -9
View File
@@ -376,7 +376,8 @@ pub async fn reauth_passkey_finish(
{
Ok(false) => {
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
let _ =
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
return (
StatusCode::UNAUTHORIZED,
Json(json!({
@@ -494,14 +495,14 @@ pub async fn check_reauth_required_cached(
did: &str,
) -> bool {
let cache_key = format!("reauth:{}", did);
if let Some(timestamp_str) = cache.get(&cache_key).await {
if let Ok(timestamp) = timestamp_str.parse::<i64>() {
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
if let Some(t) = reauth_time {
let elapsed = Utc::now().signed_duration_since(t);
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
return false;
}
if let Some(timestamp_str) = cache.get(&cache_key).await
&& let Ok(timestamp) = timestamp_str.parse::<i64>()
{
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
if let Some(t) = reauth_time {
let elapsed = Utc::now().signed_duration_since(t);
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
return false;
}
}
}
+20 -16
View File
@@ -66,7 +66,9 @@ pub async fn get_service_auth(
}
};
let (token, is_dpop) = if auth_header.len() >= 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") {
let (token, is_dpop) = if auth_header.len() >= 7
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
{
(auth_header[7..].trim().to_string(), false)
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
(auth_header[5..].trim().to_string(), true)
@@ -81,10 +83,14 @@ pub async fn get_service_auth(
&token,
dpop_proof,
"GET",
&format!("/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
params.aud,
params.lxm.as_deref().unwrap_or("")),
).await {
&format!(
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
params.aud,
params.lxm.as_deref().unwrap_or("")
),
)
.await
{
Ok(result) => crate::auth::AuthenticatedUser {
did: result.did,
is_oauth: true,
@@ -100,7 +106,8 @@ pub async fn get_service_auth(
"error": "use_dpop_nonce",
"message": "DPoP nonce required"
})),
).into_response();
)
.into_response();
}
Err(e) => {
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
@@ -110,7 +117,8 @@ pub async fn get_service_auth(
"error": "AuthenticationFailed",
"message": format!("{:?}", e)
})),
).into_response();
)
.into_response();
}
}
} else {
@@ -136,7 +144,7 @@ pub async fn get_service_auth(
"SELECT k.key_bytes, k.encryption_version
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1"
WHERE u.did = $1",
)
.bind(&auth_user.did)
.fetch_optional(&state.db)
@@ -155,17 +163,13 @@ pub async fn get_service_auth(
}
}
Ok(None) => {
return ApiError::AuthenticationFailedMsg(
"User has no signing key".into(),
)
.into_response();
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
.into_response();
}
Err(e) => {
error!(error = ?e, "DB error fetching user key");
return ApiError::AuthenticationFailedMsg(
"Failed to get signing key".into(),
)
.into_response();
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
.into_response();
}
}
}
+51 -73
View File
@@ -8,7 +8,6 @@ use axum::{
response::{IntoResponse, Response},
};
use bcrypt::verify;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
@@ -167,10 +166,7 @@ pub async fn create_session(
let has_totp = row.totp_enabled.unwrap_or(false);
let is_legacy_login = has_totp;
if has_totp && !row.allow_legacy_login {
warn!(
"Legacy login blocked for TOTP-enabled account: {}",
row.did
);
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
return (
StatusCode::FORBIDDEN,
Json(json!({
@@ -556,6 +552,7 @@ pub async fn confirm_signup(
r#"SELECT
u.id, u.did, u.handle, u.email,
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
u.discord_id, u.telegram_username, u.signal_number,
k.key_bytes, k.encryption_version
FROM users u
JOIN user_keys k ON u.id = k.user_id
@@ -577,38 +574,46 @@ pub async fn confirm_signup(
}
};
let channel_str = match row.channel {
crate::comms::CommsChannel::Email => "email",
crate::comms::CommsChannel::Discord => "discord",
crate::comms::CommsChannel::Telegram => "telegram",
crate::comms::CommsChannel::Signal => "signal",
};
let verification = match sqlx::query!(
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
row.id,
channel_str as _
)
.fetch_optional(&state.db)
.await
{
Ok(Some(v)) => v,
Ok(None) => {
warn!("No verification code found for user: {}", input.did);
return ApiError::InvalidRequest("No pending verification".into()).into_response();
let (channel_str, identifier) = match row.channel {
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
crate::comms::CommsChannel::Discord => {
("discord", row.discord_id.clone().unwrap_or_default())
}
Err(e) => {
error!("Database error fetching verification: {:?}", e);
return ApiError::InternalError.into_response();
crate::comms::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
crate::comms::CommsChannel::Signal => {
("signal", row.signal_number.clone().unwrap_or_default())
}
};
if verification.code != input.verification_code {
warn!("Invalid verification code for user: {}", input.did);
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
}
if verification.expires_at < Utc::now() {
warn!("Verification code expired for user: {}", input.did);
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
let normalized_token =
crate::auth::verification_token::normalize_token_input(&input.verification_code);
match crate::auth::verification_token::verify_signup_token(
&normalized_token,
channel_str,
&identifier,
) {
Ok(token_data) => {
if token_data.did != input.did {
warn!(
"Token DID mismatch for confirm_signup: expected {}, got {}",
input.did, token_data.did
);
return ApiError::InvalidRequest("Invalid verification code".into())
.into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
warn!("Verification code expired for user: {}", input.did);
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
.into_response();
}
Err(e) => {
warn!("Invalid verification code for user {}: {:?}", input.did, e);
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
}
}
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
@@ -634,17 +639,6 @@ pub async fn confirm_signup(
return ApiError::InternalError.into_response();
}
if let Err(e) = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
row.id,
channel_str as _
)
.execute(&state.db)
.await
{
error!("Failed to delete verification record: {:?}", e);
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
Ok(m) => m,
Err(e) => {
@@ -737,8 +731,6 @@ pub async fn resend_verification(
if is_verified {
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
}
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
let (channel_str, recipient) = match row.channel {
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
@@ -754,31 +746,17 @@ pub async fn resend_verification(
}
};
if let Err(e) = sqlx::query!(
r#"
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
VALUES ($1, $2::comms_channel, $3, $4, $5)
ON CONFLICT (user_id, channel) DO UPDATE
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
"#,
row.id,
channel_str as _,
verification_code,
recipient,
code_expires_at
)
.execute(&state.db)
.await
{
error!("Failed to update verification code: {:?}", e);
return ApiError::InternalError.into_response();
}
let verification_token =
crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
row.id,
channel_str,
&recipient,
&verification_code,
&formatted_token,
None,
)
.await
@@ -886,8 +864,7 @@ pub async fn list_sessions(
Ok(rows) => {
for (id, token_id, created_at, expires_at, client_id) in rows {
let client_name = extract_client_name(&client_id);
let is_current_oauth = auth.0.is_oauth
&& current_jti.as_ref() == Some(&token_id);
let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id);
sessions.push(SessionInfo {
id: format!("oauth:{}", id),
session_type: "oauth".to_string(),
@@ -1071,11 +1048,12 @@ pub async fn revoke_all_sessions(
.into_response();
}
} else {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
if let Err(e) =
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return (
+101
View File
@@ -0,0 +1,101 @@
use axum::{Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{info, warn};
use crate::state::AppState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyMigrationEmailInput {
pub token: String,
pub email: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyMigrationEmailOutput {
pub success: bool,
pub did: String,
}
pub async fn verify_migration_email(
State(state): State<AppState>,
Json(input): Json<VerifyMigrationEmailInput>,
) -> Result<Json<VerifyMigrationEmailOutput>, (StatusCode, Json<serde_json::Value>)> {
let token_input = super::verify_token::VerifyTokenInput {
token: input.token,
identifier: input.email,
};
let result = super::verify_token::verify_token_internal(&state, None, token_input).await?;
Ok(Json(VerifyMigrationEmailOutput {
success: result.success,
did: result.did.clone(),
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendMigrationVerificationInput {
pub email: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendMigrationVerificationOutput {
pub sent: bool,
}
pub async fn resend_migration_verification(
State(state): State<AppState>,
Json(input): Json<ResendMigrationVerificationInput>,
) -> Result<Json<ResendMigrationVerificationOutput>, (StatusCode, Json<serde_json::Value>)> {
let email = input.email.trim().to_lowercase();
let user = sqlx::query!(
"SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1",
email
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
warn!(error = %e, "Database error during resend verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
})?;
let user = match user {
Some(u) => u,
None => {
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
}
};
if user.email_verified {
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if let Err(e) = crate::comms::enqueue_migration_verification(
&state.db,
user.id,
&email,
&formatted_token,
&hostname,
)
.await
{
warn!(error = %e, "Failed to enqueue migration verification email");
}
info!(did = %user.did, "Resent migration verification email");
Ok(Json(ResendMigrationVerificationOutput { sent: true }))
}
+391
View File
@@ -0,0 +1,391 @@
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use crate::auth::verification_token::{
VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature,
};
use crate::state::AppState;
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VerifyTokenInput {
pub token: String,
pub identifier: String,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VerifyTokenOutput {
pub success: bool,
pub did: String,
pub purpose: String,
pub channel: String,
}
pub async fn verify_token(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<VerifyTokenInput>,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
verify_token_internal(&state, Some(&headers), input).await
}
pub async fn verify_token_internal(
state: &AppState,
headers: Option<&HeaderMap>,
input: VerifyTokenInput,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
let normalized_token = normalize_token_input(&input.token);
let identifier = input.identifier.trim().to_lowercase();
let token_data = match verify_token_signature(&normalized_token) {
Ok(data) => data,
Err(e) => {
let (status, error, message) = match e {
VerifyError::InvalidFormat => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token is invalid or malformed",
),
VerifyError::UnsupportedVersion => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"This verification token version is not supported",
),
VerifyError::Expired => (
StatusCode::BAD_REQUEST,
"ExpiredToken",
"The verification token has expired. Please request a new one.",
),
VerifyError::InvalidSignature => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token signature is invalid",
),
_ => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token is not valid",
),
};
warn!(error = ?e, "Token verification failed");
return Err((status, Json(json!({ "error": error, "message": message }))));
}
};
let expected_hash = crate::auth::verification_token::hash_identifier(&identifier);
if token_data.identifier_hash != expected_hash {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }),
),
));
}
match token_data.purpose {
VerificationPurpose::Migration => {
handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier)
.await
}
VerificationPurpose::ChannelUpdate => {
let auth_did = extract_and_validate_auth(state, headers).await?;
if auth_did != token_data.did {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "InvalidToken", "message": "Token does not match authenticated account" }),
),
));
}
handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await
}
VerificationPurpose::Signup => {
handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier)
.await
}
}
}
async fn extract_and_validate_auth(
state: &AppState,
headers: Option<&HeaderMap>,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
let headers = headers.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
)
})?;
let token = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
)
})?;
let user = crate::auth::validate_bearer_token(&state.db, &token)
.await
.map_err(|_| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "AuthenticationFailed", "message": "Invalid authentication token" })),
)
})?;
Ok(user.did)
}
async fn handle_migration_verification(
state: &AppState,
did: &str,
channel: &str,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
if channel != "email" {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }),
),
));
}
let user = sqlx::query!(
"SELECT id, email, email_verified FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
warn!(error = %e, "Database error during migration verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
})?;
let user = user.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
)
})?;
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }),
),
));
}
if !user.email_verified {
sqlx::query!(
"UPDATE users SET email_verified = true WHERE id = $1",
user.id
)
.execute(&state.db)
.await
.map_err(|e| {
warn!(error = %e, "Failed to update email_verified status");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to verify email" })),
)
})?;
}
info!(did = %did, "Migration email verified successfully");
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
purpose: "migration".to_string(),
channel: channel.to_string(),
}))
}
async fn handle_channel_update(
state: &AppState,
did: &str,
channel: &str,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_one(&state.db)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "User not found" })),
)
})?;
let update_result = match channel {
"email" => sqlx::query!(
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
identifier,
user_id
).execute(&state.db).await,
"discord" => sqlx::query!(
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
identifier,
user_id
).execute(&state.db).await,
"telegram" => sqlx::query!(
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
identifier,
user_id
).execute(&state.db).await,
"signal" => sqlx::query!(
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
identifier,
user_id
).execute(&state.db).await,
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
));
}
};
if let Err(e) = update_result {
error!("Failed to update user channel: {:?}", e);
if channel == "email"
&& e.as_database_error()
.map(|db| db.is_unique_violation())
.unwrap_or(false)
{
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "EmailTaken", "message": "Email already in use" })),
));
}
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to update channel" })),
));
}
info!(did = %did, channel = %channel, "Channel verified successfully");
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
purpose: "channel_update".to_string(),
channel: channel.to_string(),
}))
}
async fn handle_signup_verification(
state: &AppState,
did: &str,
channel: &str,
_identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
let user = sqlx::query!(
"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
warn!(error = %e, "Database error during signup verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
})?;
let user = user.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
)
})?;
let is_verified = user.email_verified
|| user.discord_verified
|| user.telegram_verified
|| user.signal_verified;
if is_verified {
info!(did = %did, "Account already verified");
return Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
purpose: "signup".to_string(),
channel: channel.to_string(),
}));
}
let update_result = match channel {
"email" => {
sqlx::query!(
"UPDATE users SET email_verified = TRUE WHERE id = $1",
user.id
)
.execute(&state.db)
.await
}
"discord" => {
sqlx::query!(
"UPDATE users SET discord_verified = TRUE WHERE id = $1",
user.id
)
.execute(&state.db)
.await
}
"telegram" => {
sqlx::query!(
"UPDATE users SET telegram_verified = TRUE WHERE id = $1",
user.id
)
.execute(&state.db)
.await
}
"signal" => {
sqlx::query!(
"UPDATE users SET signal_verified = TRUE WHERE id = $1",
user.id
)
.execute(&state.db)
.await
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
));
}
};
update_result.map_err(|e| {
warn!(error = %e, "Failed to update channel verified status");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })),
)
})?;
info!(did = %did, channel = %channel, "Signup verified successfully");
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
purpose: "signup".to_string(),
channel: channel.to_string(),
}))
}
+8 -8
View File
@@ -64,16 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
return Err(HandleValidationError::TooLong);
}
if let Some(first_char) = handle.chars().next() {
if first_char == '-' || first_char == '_' {
return Err(HandleValidationError::StartsWithInvalidChar);
}
if let Some(first_char) = handle.chars().next()
&& (first_char == '-' || first_char == '_')
{
return Err(HandleValidationError::StartsWithInvalidChar);
}
if let Some(last_char) = handle.chars().last() {
if last_char == '-' || last_char == '_' {
return Err(HandleValidationError::EndsWithInvalidChar);
}
if let Some(last_char) = handle.chars().last()
&& (last_char == '-' || last_char == '_')
{
return Err(HandleValidationError::EndsWithInvalidChar);
}
for c in handle.chars() {
+8 -176
View File
@@ -1,20 +1,18 @@
use crate::auth::validate_bearer_token;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmChannelVerificationInput {
pub channel: String,
pub identifier: String,
pub code: String,
}
@@ -23,179 +21,13 @@ pub async fn confirm_channel_verification(
headers: HeaderMap,
Json(input): Json<ConfirmChannelVerificationInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response();
}
let token_input = crate::api::server::VerifyTokenInput {
token: input.code,
identifier: input.identifier,
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
.fetch_one(&state.db)
.await
{
Ok(id) => id,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
};
let channel_str = input.channel.as_str();
if !["email", "discord", "telegram", "signal"].contains(&channel_str) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})),
)
.into_response();
match crate::api::server::verify_token_internal(&state, Some(&headers), token_input).await {
Ok(output) => Json(json!({"success": output.success})).into_response(),
Err((status, err_json)) => (status, err_json).into_response(),
}
let record = match sqlx::query!(
r#"
SELECT code, pending_identifier, expires_at FROM channel_verifications
WHERE user_id = $1 AND channel = $2::comms_channel
"#,
user_id,
channel_str as _
)
.fetch_optional(&state.db)
.await {
Ok(Some(r)) => r,
Ok(None) => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})),
)
.into_response(),
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response(),
};
let pending_identifier =
match record.pending_identifier {
Some(p) => p,
None => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
)
.into_response(),
};
if record.expires_at < Utc::now() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})),
)
.into_response();
}
if record.code != input.code {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})),
)
.into_response();
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let update_result = match channel_str {
"email" => sqlx::query!(
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
pending_identifier,
user_id
).execute(&mut *tx).await,
"discord" => sqlx::query!(
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
pending_identifier,
user_id
).execute(&mut *tx).await,
"telegram" => sqlx::query!(
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
pending_identifier,
user_id
).execute(&mut *tx).await,
"signal" => sqlx::query!(
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
pending_identifier,
user_id
).execute(&mut *tx).await,
_ => unreachable!(),
};
if let Err(e) = update_result {
error!("Failed to update user channel: {:?}", e);
if channel_str == "email"
&& e.as_database_error()
.map(|db| db.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
)
.into_response();
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to update channel"})),
)
.into_response();
}
if let Err(e) = sqlx::query!(
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
user_id,
channel_str as _
)
.execute(&mut *tx)
.await
{
error!("Failed to delete verification record: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if tx.commit().await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!(did = %user.did, channel = %channel_str, "Channel verified successfully");
Json(json!({"success": true})).into_response()
}
+1
View File
@@ -12,6 +12,7 @@ pub mod scope_check;
pub mod service;
pub mod token;
pub mod totp;
pub mod verification_token;
pub mod verify;
pub mod webauthn;
+423
View File
@@ -0,0 +1,423 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::Mac;
use sha2::{Digest, Sha256};
type HmacSha256 = hmac::Hmac<Sha256>;
const TOKEN_VERSION: u8 = 1;
const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30;
const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48;
const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationPurpose {
Signup,
Migration,
ChannelUpdate,
}
impl VerificationPurpose {
fn as_str(&self) -> &'static str {
match self {
Self::Signup => "signup",
Self::Migration => "migration",
Self::ChannelUpdate => "channel_update",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"signup" => Some(Self::Signup),
"migration" => Some(Self::Migration),
"channel_update" => Some(Self::ChannelUpdate),
_ => None,
}
}
fn default_expiry_seconds(&self) -> u64 {
match self {
Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60,
Self::Migration => DEFAULT_MIGRATION_EXPIRY_HOURS * 3600,
Self::ChannelUpdate => DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES * 60,
}
}
}
#[derive(Debug)]
pub struct VerificationToken {
pub did: String,
pub purpose: VerificationPurpose,
pub channel: String,
pub identifier_hash: String,
pub expires_at: u64,
}
fn derive_verification_key() -> [u8; 32] {
use hkdf::Hkdf;
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!("MASTER_KEY must be set");
}
});
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key = [0u8; 32];
hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
.expect("HKDF expansion failed");
key
}
pub fn hash_identifier(identifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(identifier.to_lowercase().as_bytes());
let result = hasher.finalize();
URL_SAFE_NO_PAD.encode(&result[..16])
}
pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String {
generate_token(did, VerificationPurpose::Signup, channel, identifier)
}
pub fn generate_migration_token(did: &str, email: &str) -> String {
generate_token(did, VerificationPurpose::Migration, "email", email)
}
pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String {
generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier)
}
pub fn generate_token(
did: &str,
purpose: VerificationPurpose,
channel: &str,
identifier: &str,
) -> String {
generate_token_with_expiry(
did,
purpose,
channel,
identifier,
purpose.default_expiry_seconds(),
)
}
pub fn generate_token_with_expiry(
did: &str,
purpose: VerificationPurpose,
channel: &str,
identifier: &str,
expiry_seconds: u64,
) -> String {
let key = derive_verification_key();
let identifier_hash = hash_identifier(identifier);
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
+ expiry_seconds;
let payload = format!(
"{}|{}|{}|{}|{}",
did,
purpose.as_str(),
channel,
identifier_hash,
expires_at
);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
let token_data = format!(
"{}|{}|{}|{}|{}|{}|{}",
TOKEN_VERSION,
did,
purpose.as_str(),
channel,
identifier_hash,
expires_at,
signature
);
URL_SAFE_NO_PAD.encode(token_data.as_bytes())
}
#[derive(Debug)]
pub enum VerifyError {
InvalidFormat,
UnsupportedVersion,
Expired,
InvalidSignature,
IdentifierMismatch,
PurposeMismatch,
ChannelMismatch,
}
impl std::fmt::Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid token format"),
Self::UnsupportedVersion => write!(f, "Unsupported token version"),
Self::Expired => write!(f, "Token has expired"),
Self::InvalidSignature => write!(f, "Invalid token signature"),
Self::IdentifierMismatch => write!(f, "Identifier does not match token"),
Self::PurposeMismatch => write!(f, "Token purpose does not match"),
Self::ChannelMismatch => write!(f, "Token channel does not match"),
}
}
}
pub fn verify_signup_token(
token: &str,
expected_channel: &str,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.purpose != VerificationPurpose::Signup {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != expected_channel {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_identifier);
if parsed.identifier_hash != expected_hash {
return Err(VerifyError::IdentifierMismatch);
}
Ok(parsed)
}
pub fn verify_migration_token(
token: &str,
expected_email: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.purpose != VerificationPurpose::Migration {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != "email" {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_email);
if parsed.identifier_hash != expected_hash {
return Err(VerifyError::IdentifierMismatch);
}
Ok(parsed)
}
pub fn verify_channel_update_token(
token: &str,
expected_channel: &str,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.purpose != VerificationPurpose::ChannelUpdate {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != expected_channel {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_identifier);
if parsed.identifier_hash != expected_hash {
return Err(VerifyError::IdentifierMismatch);
}
Ok(parsed)
}
pub fn verify_token_for_did(
token: &str,
expected_did: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.did != expected_did {
return Err(VerifyError::IdentifierMismatch);
}
Ok(parsed)
}
pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyError> {
let token_bytes = URL_SAFE_NO_PAD
.decode(token.trim())
.map_err(|_| VerifyError::InvalidFormat)?;
let token_str = String::from_utf8(token_bytes).map_err(|_| VerifyError::InvalidFormat)?;
let parts: Vec<&str> = token_str.split('|').collect();
if parts.len() != 7 {
return Err(VerifyError::InvalidFormat);
}
let version: u8 = parts[0].parse().map_err(|_| VerifyError::InvalidFormat)?;
if version != TOKEN_VERSION {
return Err(VerifyError::UnsupportedVersion);
}
let did = parts[1];
let purpose_str = parts[2];
let channel = parts[3];
let identifier_hash = parts[4];
let expires_at: u64 = parts[5].parse().map_err(|_| VerifyError::InvalidFormat)?;
let provided_signature = parts[6];
let purpose = VerificationPurpose::from_str(purpose_str).ok_or(VerifyError::InvalidFormat)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now > expires_at {
return Err(VerifyError::Expired);
}
let key = derive_verification_key();
let payload = format!(
"{}|{}|{}|{}|{}",
did, purpose_str, channel, identifier_hash, expires_at
);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
mac.update(payload.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
let sig_matches: bool = provided_signature
.as_bytes()
.ct_eq(expected_signature.as_bytes())
.into();
if !sig_matches {
return Err(VerifyError::InvalidSignature);
}
Ok(VerificationToken {
did: did.to_string(),
purpose,
channel: channel.to_string(),
identifier_hash: identifier_hash.to_string(),
expires_at,
})
}
pub fn format_token_for_display(token: &str) -> String {
let clean = token.replace(['-', ' '], "");
let mut result = String::new();
for (i, c) in clean.chars().enumerate() {
if i > 0 && i % 4 == 0 {
result.push('-');
}
result.push(c);
}
result
}
pub fn normalize_token_input(input: &str) -> String {
input
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '=')
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signup_token() {
let did = "did:plc:test123";
let channel = "email";
let identifier = "test@example.com";
let token = generate_signup_token(did, channel, identifier);
let result = verify_signup_token(&token, channel, identifier);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
assert_eq!(parsed.did, did);
assert_eq!(parsed.purpose, VerificationPurpose::Signup);
assert_eq!(parsed.channel, channel);
}
#[test]
fn test_migration_token() {
let did = "did:plc:test123";
let email = "test@example.com";
let token = generate_migration_token(did, email);
let result = verify_migration_token(&token, email);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
assert_eq!(parsed.did, did);
assert_eq!(parsed.purpose, VerificationPurpose::Migration);
}
#[test]
fn test_token_case_insensitive() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "Test@Example.COM");
let result = verify_signup_token(&token, "email", "test@example.com");
assert!(result.is_ok());
}
#[test]
fn test_token_wrong_identifier() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "test@example.com");
let result = verify_signup_token(&token, "email", "other@example.com");
assert!(matches!(result, Err(VerifyError::IdentifierMismatch)));
}
#[test]
fn test_token_wrong_channel() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "test@example.com");
let result = verify_signup_token(&token, "discord", "test@example.com");
assert!(matches!(result, Err(VerifyError::ChannelMismatch)));
}
#[test]
fn test_expired_token() {
let did = "did:plc:test123";
let token = generate_token_with_expiry(
did,
VerificationPurpose::Signup,
"email",
"test@example.com",
0,
);
std::thread::sleep(std::time::Duration::from_millis(1100));
let result = verify_signup_token(&token, "email", "test@example.com");
assert!(matches!(result, Err(VerifyError::Expired)));
}
#[test]
fn test_invalid_token() {
let result = verify_signup_token("invalid-token", "email", "test@example.com");
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
}
#[test]
fn test_purpose_mismatch() {
let did = "did:plc:test123";
let email = "test@example.com";
let signup_token = generate_signup_token(did, "email", email);
let result = verify_migration_token(&signup_token, email);
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
}
#[test]
fn test_discord_channel() {
let did = "did:plc:test123";
let discord_id = "123456789012345678";
let token = generate_signup_token(did, "discord", discord_id);
let result = verify_signup_token(&token, "discord", discord_id);
assert!(result.is_ok());
}
#[test]
fn test_format_token_for_display() {
let token = "ABCDEFGHIJKLMNOP";
let formatted = format_token_for_display(token);
assert_eq!(formatted, "ABCD-EFGH-IJKL-MNOP");
}
#[test]
fn test_normalize_token_input() {
let input = "ABCD-EFGH IJKL-MNOP";
let normalized = normalize_token_input(input);
assert_eq!(normalized, "ABCDEFGHIJKLMNOP");
}
}
+28 -28
View File
@@ -12,8 +12,6 @@ pub fn validate_locale(locale: &str) -> &str {
pub struct NotificationStrings {
pub welcome_subject: &'static str,
pub welcome_body: &'static str,
pub email_verification_subject: &'static str,
pub email_verification_body: &'static str,
pub password_reset_subject: &'static str,
pub password_reset_body: &'static str,
pub email_update_subject: &'static str,
@@ -30,6 +28,8 @@ pub struct NotificationStrings {
pub signup_verification_body: &'static str,
pub legacy_login_subject: &'static str,
pub legacy_login_body: &'static str,
pub migration_verification_subject: &'static str,
pub migration_verification_body: &'static str,
}
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
@@ -46,12 +46,10 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings {
static STRINGS_EN: NotificationStrings = NotificationStrings {
welcome_subject: "Welcome to {hostname}",
welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.",
email_verification_subject: "Verify your email - {hostname}",
email_verification_body: "Hello @{handle},\n\nYour email verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
password_reset_subject: "Password Reset - {hostname}",
password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
email_update_subject: "Confirm your new email - {hostname}",
email_update_body: "Hello @{handle},\n\nYour email update confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
account_deletion_subject: "Account Deletion Request - {hostname}",
account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
plc_operation_subject: "{hostname} - PLC Operation Token",
@@ -61,20 +59,20 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "Account Recovery - {hostname}",
passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.",
signup_verification_subject: "Verify your account - {hostname}",
signup_verification_body: "Welcome! Your account verification code is: {code}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {hostname}.",
signup_verification_body: "Welcome! Your verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 30 minutes.\n\nIf you did not create an account on {hostname}, please ignore this message.\n\n(Or if you like to live dangerously: {verify_link})",
legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}",
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
migration_verification_subject: "Verify your email - {hostname}",
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nIf you did not migrate your account, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
};
static STRINGS_ZH: NotificationStrings = NotificationStrings {
welcome_subject: "欢迎加入 {hostname}",
welcome_body: "欢迎加入 {hostname}\n\n您的用户名是:@{handle}\n\n感谢您的加入。",
email_verification_subject: "验证您的邮箱 - {hostname}",
email_verification_body: "您好 @{handle}\n\n您的邮箱验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
password_reset_subject: "密码重置 - {hostname}",
password_reset_body: "您好 @{handle}\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。",
email_update_subject: "确认您的新邮箱 - {hostname}",
email_update_body: "您好 @{handle}\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
email_update_body: "您好 @{handle}\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。\n\n(或者直接点击链接:{verify_link}",
account_deletion_subject: "账户删除请求 - {hostname}",
account_deletion_body: "您好 @{handle}\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。",
plc_operation_subject: "{hostname} - PLC 操作令牌",
@@ -84,20 +82,20 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "账户恢复 - {hostname}",
passkey_recovery_body: "您好 @{handle}\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。",
signup_verification_subject: "验证您的账户 - {hostname}",
signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。",
signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。\n\n(或者直接点击链接:{verify_link}",
legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}",
legacy_login_body: "您好 @{handle}\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
migration_verification_subject: "验证您的邮箱 - {hostname}",
migration_verification_body: "欢迎来到 {hostname}\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n如果您没有迁移账户,请忽略此邮件。\n\n(或者直接点击链接:{verify_link}",
};
static STRINGS_JA: NotificationStrings = NotificationStrings {
welcome_subject: "{hostname} へようこそ",
welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。",
email_verification_subject: "メール認証 - {hostname}",
email_verification_body: "@{handle} 様\n\nメール認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
password_reset_subject: "パスワードリセット - {hostname}",
password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。",
email_update_subject: "新しいメールアドレスの確認 - {hostname}",
email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link}",
account_deletion_subject: "アカウント削除リクエスト - {hostname}",
account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。",
plc_operation_subject: "{hostname} - PLC 操作トークン",
@@ -107,20 +105,20 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "アカウント復旧 - {hostname}",
passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。",
signup_verification_subject: "アカウント認証 - {hostname}",
signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。",
signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link}",
legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}",
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
migration_verification_subject: "メールアドレスの認証 - {hostname}",
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\nアカウントを移行していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link}",
};
static STRINGS_KO: NotificationStrings = NotificationStrings {
welcome_subject: "{hostname}에 오신 것을 환영합니다",
welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.",
email_verification_subject: "이메일 인증 - {hostname}",
email_verification_body: "안녕하세요 @{handle}님,\n\n이메일 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
password_reset_subject: "비밀번호 재설정 - {hostname}",
password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.",
email_update_subject: "새 이메일 확인 - {hostname}",
email_update_body: "안녕하세요 @{handle}님,\n\n이메일 업데이트 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
email_update_subject: "새 이메일 주소 확인 - {hostname}",
email_update_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
account_deletion_subject: "계정 삭제 요청 - {hostname}",
account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.",
plc_operation_subject: "{hostname} - PLC 작업 토큰",
@@ -130,20 +128,20 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "계정 복구 - {hostname}",
passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.",
signup_verification_subject: "계정 인증 - {hostname}",
signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.",
signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}",
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
migration_verification_subject: "이메일 인증 - {hostname}",
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
};
static STRINGS_SV: NotificationStrings = NotificationStrings {
welcome_subject: "Välkommen till {hostname}",
welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.",
email_verification_subject: "Verifiera din e-post - {hostname}",
email_verification_body: "Hej @{handle},\n\nDin e-postverifieringskod är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
password_reset_subject: "Lösenordsåterställning - {hostname}",
password_reset_body: "Hej @{handle},\n\nDin kod för lösenordsåterställning är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
email_update_subject: "Bekräfta din nya e-post - {hostname}",
email_update_body: "Hej @{handle},\n\nDin bekräftelsekod för e-postuppdatering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
email_update_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
account_deletion_subject: "Begäran om kontoradering - {hostname}",
account_deletion_body: "Hej @{handle},\n\nDin bekräftelsekod för kontoradering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.",
plc_operation_subject: "{hostname} - PLC-operationstoken",
@@ -153,20 +151,20 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "Kontoåterställning - {hostname}",
passkey_recovery_body: "Hej @{handle},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{url}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.",
signup_verification_subject: "Verifiera ditt konto - {hostname}",
signup_verification_body: "Välkommen! Din kontoverifieringskod är: {code}\n\nDenna kod upphör om 30 minuter.\n\nAnge denna kod för att slutföra din registrering på {hostname}.",
signup_verification_body: "Välkommen! Din verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 30 minuter.\n\nOm du inte skapade ett konto på {hostname}, ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}",
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
migration_verification_subject: "Verifiera din e-post - {hostname}",
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
};
static STRINGS_FI: NotificationStrings = NotificationStrings {
welcome_subject: "Tervetuloa palveluun {hostname}",
welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.",
email_verification_subject: "Vahvista sähköpostisi - {hostname}",
email_verification_body: "Hei @{handle},\n\nSähköpostin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
password_reset_subject: "Salasanan palautus - {hostname}",
password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
email_update_subject: "Vahvista uusi sähköpostiosoitteesi - {hostname}",
email_update_body: "Hei @{handle},\n\nSähköpostin päivityksen vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
email_update_subject: "Vahvista uusi sähköpostisi - {hostname}",
email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
account_deletion_subject: "Tilin poistopyyntö - {hostname}",
account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.",
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
@@ -176,9 +174,11 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
passkey_recovery_subject: "Tilin palautus - {hostname}",
passkey_recovery_body: "Hei @{handle},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{url}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.",
signup_verification_subject: "Vahvista tilisi - {hostname}",
signup_verification_body: "Tervetuloa! Tilin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 30 minuutissa.\n\nSyötä tämä koodi viimeistelläksesi rekisteröintisi palveluun {hostname}.",
signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 30 minuutissa.\n\nJos et luonut tiliä palveluun {hostname}, jätä tämä viesti huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}",
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+1 -1
View File
@@ -10,7 +10,7 @@ pub use sender::{
pub use service::{
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
queue_legacy_login_notification,
};
+81 -34
View File
@@ -313,34 +313,6 @@ pub async fn enqueue_welcome(
.await
}
pub async fn enqueue_email_verification(
db: &PgPool,
user_id: Uuid,
email: &str,
handle: &str,
code: &str,
hostname: &str,
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let body = format_message(
strings.email_verification_body,
&[("handle", handle), ("code", code)],
);
let subject = format_message(strings.email_verification_subject, &[("hostname", hostname)]);
enqueue_comms(
db,
NewComms::email(
user_id,
super::types::CommsType::EmailVerification,
email.to_string(),
subject,
body,
),
)
.await
}
pub async fn enqueue_password_reset(
db: &PgPool,
user_id: Uuid,
@@ -378,9 +350,21 @@ pub async fn enqueue_email_update(
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let encoded_email = urlencoding::encode(new_email);
let encoded_token = urlencoding::encode(code);
let verify_page = format!("https://{}/#/verify", hostname);
let verify_link = format!(
"https://{}/#/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
);
let body = format_message(
strings.email_update_body,
&[("handle", handle), ("code", code)],
&[
("handle", handle),
("code", code),
("verify_page", &verify_page),
("verify_link", &verify_link),
],
);
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
enqueue_comms(
@@ -530,14 +514,33 @@ pub async fn enqueue_signup_verification(
_ => CommsChannel::Email,
};
let strings = get_strings(locale.unwrap_or("en"));
let (verify_page, verify_link) = if comms_channel == CommsChannel::Email {
let encoded_email = urlencoding::encode(recipient);
let encoded_token = urlencoding::encode(code);
(
format!("https://{}/#/verify", hostname),
format!(
"https://{}/#/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
),
)
} else {
(String::new(), String::new())
};
let body = format_message(
strings.signup_verification_body,
&[("code", code), ("hostname", &hostname)],
&[
("code", code),
("hostname", &hostname),
("verify_page", &verify_page),
("verify_link", &verify_link),
],
);
let subject = match comms_channel {
CommsChannel::Email => {
Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)]))
}
CommsChannel::Email => Some(format_message(
strings.signup_verification_subject,
&[("hostname", &hostname)],
)),
_ => None,
};
enqueue_comms(
@@ -554,6 +557,48 @@ pub async fn enqueue_signup_verification(
.await
}
pub async fn enqueue_migration_verification(
db: &PgPool,
user_id: Uuid,
email: &str,
token: &str,
hostname: &str,
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let encoded_email = urlencoding::encode(email);
let encoded_token = urlencoding::encode(token);
let verify_page = format!("https://{}/#/verify", hostname);
let verify_link = format!(
"https://{}/#/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
);
let body = format_message(
strings.migration_verification_body,
&[
("code", token),
("hostname", hostname),
("verify_page", &verify_page),
("verify_link", &verify_link),
],
);
let subject = format_message(
strings.migration_verification_subject,
&[("hostname", hostname)],
);
enqueue_comms(
db,
NewComms::email(
user_id,
super::types::CommsType::MigrationVerification,
email.to_string(),
subject,
body,
),
)
.await
}
pub async fn queue_legacy_login_notification(
db: &PgPool,
user_id: Uuid,
@@ -563,7 +608,9 @@ pub async fn queue_legacy_login_notification(
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
let timestamp = chrono::Utc::now()
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string();
let body = format_message(
strings.legacy_login_body,
&[
+1
View File
@@ -34,6 +34,7 @@ pub enum CommsType {
TwoFactorCode,
PasskeyRecovery,
LegacyLoginAlert,
MigrationVerification,
}
#[derive(Debug, Clone, FromRow)]
+5 -2
View File
@@ -114,8 +114,11 @@ impl AuthConfig {
.expect("HKDF expansion failed");
let mut device_cookie_key = [0u8; 32];
hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key)
.expect("HKDF expansion failed");
hk.expand(
b"tranquil-pds-device-cookie-signing",
&mut device_cookie_key,
)
.expect("HKDF expansion failed");
AuthConfig {
jwt_secret,
+12
View File
@@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.server.reserveSigningKey",
post(api::server::reserve_signing_key),
)
.route(
"/xrpc/com.atproto.server.verifyMigrationEmail",
post(api::server::verify_migration_email),
)
.route(
"/xrpc/com.atproto.server.resendMigrationVerification",
post(api::server::resend_migration_verification),
)
.route(
"/xrpc/com.atproto.identity.updateHandle",
post(api::identity::update_handle),
@@ -550,6 +558,10 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.confirmChannelVerification",
post(api::verification::confirm_channel_verification),
)
.route(
"/xrpc/com.tranquil.account.verifyToken",
post(api::server::verify_token),
)
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
+2 -1
View File
@@ -172,7 +172,8 @@ pub async fn frontend_client_metadata(
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
.to_string(),
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
dpop_bound_access_tokens: true,
+4 -3
View File
@@ -74,9 +74,10 @@ impl RateLimiters {
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
))),
totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
totp_verify: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
)),
}
}
+51 -13
View File
@@ -458,19 +458,57 @@ pub fn validate_password(password: &str) -> Result<(), PasswordValidationError>
fn is_common_password(password: &str) -> bool {
const COMMON_PASSWORDS: &[&str] = &[
"password", "Password1", "Password123", "Passw0rd", "Passw0rd!",
"12345678", "123456789", "1234567890",
"qwerty123", "Qwerty123", "qwertyui", "Qwertyui",
"letmein1", "Letmein1", "welcome1", "Welcome1",
"admin123", "Admin123", "password1", "Password1!",
"iloveyou", "Iloveyou1", "monkey123", "Monkey123",
"dragon12", "Dragon123", "master12", "Master123",
"login123", "Login123", "abc12345", "Abc12345",
"football", "Football1", "baseball", "Baseball1",
"trustno1", "Trustno1", "sunshine", "Sunshine1",
"princess", "Princess1", "computer", "Computer1",
"whatever", "Whatever1", "nintendo", "Nintendo1",
"bluesky1", "Bluesky1", "Bluesky123",
"password",
"Password1",
"Password123",
"Passw0rd",
"Passw0rd!",
"12345678",
"123456789",
"1234567890",
"qwerty123",
"Qwerty123",
"qwertyui",
"Qwertyui",
"letmein1",
"Letmein1",
"welcome1",
"Welcome1",
"admin123",
"Admin123",
"password1",
"Password1!",
"iloveyou",
"Iloveyou1",
"monkey123",
"Monkey123",
"dragon12",
"Dragon123",
"master12",
"Master123",
"login123",
"Login123",
"abc12345",
"Abc12345",
"football",
"Football1",
"baseball",
"Baseball1",
"trustno1",
"Trustno1",
"sunshine",
"Sunshine1",
"princess",
"Princess1",
"computer",
"Computer1",
"whatever",
"Whatever1",
"nintendo",
"Nintendo1",
"bluesky1",
"Bluesky1",
"Bluesky123",
];
let lower = password.to_lowercase();
+44 -8
View File
@@ -92,16 +92,24 @@ async fn test_verify_channel_discord() {
.await
.expect("User not found");
let code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
let row = sqlx::query!(
"SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
user_id
)
.fetch_one(&pool)
.await
.expect("Verification code not found");
let code = row
.metadata
.as_ref()
.and_then(|m| m.get("code"))
.and_then(|c| c.as_str())
.expect("No code in metadata");
let input = json!({
"channel": "discord",
"identifier": "123456789",
"code": code
});
let resp = client
@@ -153,7 +161,8 @@ async fn test_verify_channel_invalid_code() {
let input = json!({
"channel": "telegram",
"code": "000000"
"identifier": "testuser",
"code": "XXXX-XXXX-XXXX-XXXX"
});
let resp = client
.post(format!(
@@ -165,7 +174,11 @@ async fn test_verify_channel_invalid_code() {
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(
resp.status() == 400 || resp.status() == 422,
"Expected 400 or 422, got {}",
resp.status()
);
}
#[tokio::test]
@@ -176,7 +189,8 @@ async fn test_verify_channel_not_set() {
let input = json!({
"channel": "signal",
"code": "123456"
"identifier": "123456",
"code": "XXXX-XXXX-XXXX-XXXX"
});
let resp = client
.post(format!(
@@ -188,7 +202,11 @@ async fn test_verify_channel_not_set() {
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(
resp.status() == 400 || resp.status() == 422,
"Expected 400 or 422, got {}",
resp.status()
);
}
#[tokio::test]
@@ -226,16 +244,34 @@ async fn test_update_email_via_notification_prefs() {
.await
.expect("User not found");
let code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
let body_text: String = sqlx::query_scalar!(
"SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
user_id
)
.fetch_one(&pool)
.await
.expect("Verification code not found");
let code = body_text
.lines()
.skip_while(|line| !line.contains("verification code"))
.nth(1)
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty() && line.contains('-'))
.unwrap_or_else(|| {
body_text
.lines()
.find(|line| {
let trimmed = line.trim();
trimmed.starts_with("MX") && trimmed.contains('-')
})
.map(|s| s.trim().to_string())
.unwrap_or_default()
});
let input = json!({
"channel": "email",
"identifier": unique_email,
"code": code
});
let resp = client
+48 -5
View File
@@ -297,14 +297,34 @@ pub async fn verify_new_account(client: &Client, did: &str) -> String {
.connect(&conn_str)
.await
.expect("Failed to connect to test database");
let verification_code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
let body_text: String = sqlx::query_scalar!(
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
did
)
.fetch_one(&pool)
.await
.expect("Failed to get verification code");
let verification_code = body_text
.lines()
.find(|line| line.contains("verification code:") || line.contains("code is:"))
.and_then(|line| {
if line.contains("verification code:") {
line.split("verification code:")
.nth(1)
.map(|s| s.trim().to_string())
} else {
line.split("code is:").nth(1).map(|s| s.trim().to_string())
}
})
.unwrap_or_else(|| {
body_text
.lines()
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
.map(|s| s.trim().to_string())
.unwrap_or_default()
});
let confirm_payload = json!({
"did": did,
"verificationCode": verification_code
@@ -453,13 +473,36 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) ->
if let Some(access_jwt) = body["accessJwt"].as_str() {
return (access_jwt.to_string(), did);
}
let verification_code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
let body_text: String = sqlx::query_scalar!(
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
&did
)
.fetch_one(&pool)
.await
.expect("Failed to get verification code");
.expect("Failed to get verification from comms_queue");
let verification_code = body_text
.lines()
.find(|line| line.contains("verification code:") || line.contains("code is:"))
.and_then(|line| {
if line.contains("verification code:") {
line.split("verification code:")
.nth(1)
.map(|s| s.trim().to_string())
} else if line.contains("code is:") {
line.split("code is:").nth(1).map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| {
body_text
.split_whitespace()
.find(|word| {
word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3
})
.unwrap_or(&body_text)
.to_string()
});
let confirm_payload = json!({
"did": did,
+18 -14
View File
@@ -1,6 +1,6 @@
mod common;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use common::*;
use k256::ecdsa::{SigningKey, signature::Signer};
use reqwest::StatusCode;
@@ -387,13 +387,14 @@ async fn test_did_web_byod_flow() {
let mock_uri = mock_server.uri();
let mock_addr = mock_uri.trim_start_matches("http://");
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "");
let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id);
let did = format!(
"did:web:{}:byod:{}",
mock_addr.replace(":", "%3A"),
unique_id
);
let handle = format!("byod_{}", uuid::Uuid::new_v4());
let pds_endpoint = base_url().await.replace("http://", "https://");
let pds_did = format!(
"did:web:{}",
pds_endpoint.trim_start_matches("https://")
);
let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://"));
let temp_key = SigningKey::random(&mut rand::thread_rng());
let public_key_multibase = signing_key_to_multibase(&temp_key);
@@ -443,16 +444,19 @@ async fn test_did_web_byod_flow() {
let body: Value = res.json().await.expect("Response was not JSON");
let returned_did = body["did"].as_str().expect("No DID in response");
assert_eq!(returned_did, did, "Returned DID should match requested DID");
let access_jwt = body["accessJwt"]
.as_str()
.expect("No accessJwt in response");
assert_eq!(
body["verificationRequired"], true,
"BYOD accounts should require verification"
);
let access_jwt = common::verify_new_account(&client, returned_did).await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.server.checkAccountStatus",
base_url().await
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.send()
.await
.expect("Failed to check account status");
@@ -468,7 +472,7 @@ async fn test_did_web_byod_flow() {
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
base_url().await
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.send()
.await
.expect("Failed to get recommended credentials");
@@ -491,7 +495,7 @@ async fn test_did_web_byod_flow() {
"{}/xrpc/com.atproto.server.activateAccount",
base_url().await
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.send()
.await
.expect("Failed to activate account");
@@ -506,7 +510,7 @@ async fn test_did_web_byod_flow() {
"{}/xrpc/com.atproto.server.checkAccountStatus",
base_url().await
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.send()
.await
.expect("Failed to check account status");
@@ -522,7 +526,7 @@ async fn test_did_web_byod_flow() {
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
+40 -57
View File
@@ -12,6 +12,30 @@ async fn get_pool() -> PgPool {
.expect("Failed to connect to test database")
}
async fn get_email_update_token(pool: &PgPool, did: &str) -> String {
let body_text: String = sqlx::query_scalar!(
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
did
)
.fetch_one(pool)
.await
.expect("Verification not found");
body_text
.lines()
.skip_while(|line| !line.contains("verification code"))
.nth(1)
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty() && line.contains('-'))
.unwrap_or_else(|| {
body_text
.lines()
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
.map(|s| s.trim().to_string())
.unwrap_or_default()
})
}
async fn create_verified_account(
client: &reqwest::Client,
base_url: &str,
@@ -61,19 +85,7 @@ async fn test_email_update_flow_success() {
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["tokenRequired"], true);
let verification = sqlx::query!(
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
.expect("Verification not found");
assert_eq!(
verification.pending_identifier.as_deref(),
Some(new_email.as_str())
);
let code = verification.code;
let code = get_email_update_token(&pool, &did).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(&access_jwt)
@@ -90,15 +102,6 @@ async fn test_email_update_flow_success() {
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_optional(&pool)
.await
.expect("DB error");
assert!(verification.is_none());
}
#[tokio::test]
@@ -180,14 +183,7 @@ async fn test_confirm_email_wrong_email() {
.await
.expect("Failed to request email update");
assert_eq!(res.status(), StatusCode::OK);
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
.expect("Verification not found");
let code = verification.code;
let code = get_email_update_token(&pool, &did).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(&access_jwt)
@@ -200,17 +196,18 @@ async fn test_confirm_email_wrong_email() {
.expect("Failed to confirm email");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["message"], "Email does not match pending update");
assert!(
body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken"
);
}
#[tokio::test]
async fn test_update_email_success_no_token_required() {
async fn test_update_email_requires_token() {
let client = common::client();
let base_url = common::base_url().await;
let pool = get_pool().await;
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("direct_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
@@ -219,12 +216,9 @@ async fn test_update_email_success_no_token_required() {
.send()
.await
.expect("Failed to update email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "TokenRequired");
}
#[tokio::test]
@@ -299,14 +293,7 @@ async fn test_update_email_with_valid_token() {
.await
.expect("Failed to request email update");
assert_eq!(res.status(), StatusCode::OK);
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_one(&pool)
.await
.expect("Verification not found");
let code = verification.code;
let code = get_email_update_token(&pool, &did).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(&access_jwt)
@@ -323,14 +310,6 @@ async fn test_update_email_with_valid_token() {
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
)
.fetch_optional(&pool)
.await
.expect("DB error");
assert!(verification.is_none());
}
#[tokio::test]
@@ -387,7 +366,11 @@ async fn test_update_email_already_taken() {
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert!(
body["message"].as_str().unwrap().contains("already in use")
body["error"] == "TokenRequired"
|| body["message"]
.as_str()
.unwrap_or("")
.contains("already in use")
|| body["error"] == "InvalidRequest"
);
}
+21 -2
View File
@@ -688,10 +688,29 @@ async fn test_refresh_token_replay_protection() {
.connect(&get_db_connection_string().await)
.await
.unwrap();
let code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
let body_text: String = sqlx::query_scalar!(
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
did
).fetch_one(&pool).await.unwrap();
let code = body_text
.lines()
.find(|line| line.contains("verification code:") || line.contains("code is:"))
.and_then(|line| {
if line.contains("verification code:") {
line.split("verification code:")
.nth(1)
.map(|s| s.trim().to_string())
} else {
line.split("code is:").nth(1).map(|s| s.trim().to_string())
}
})
.unwrap_or_else(|| {
body_text
.lines()
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
.map(|s| s.trim().to_string())
.unwrap_or_default()
});
let confirm = http_client
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))