feat: oauth prompt=create, other frontend fixes

This commit is contained in:
lewis
2026-01-19 19:16:01 +02:00
committed by Tangled
parent 158628f1a7
commit 12a189cb2b
90 changed files with 4054 additions and 2375 deletions
@@ -1,77 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "request_uri",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "provider: SsoProviderType",
"type_info": {
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
}
},
{
"ordinal": 3,
"name": "provider_user_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "provider_username",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "provider_email",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
"query": "SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
"hash": "44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934"
}
@@ -1,77 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "request_uri",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "provider: SsoProviderType",
"type_info": {
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
}
},
{
"ordinal": 3,
"name": "provider_user_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "provider_username",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "provider_email",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, expires_at = $4\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67"
}
@@ -1,31 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
},
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6"
}
@@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
},
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a"
}
+10
View File
@@ -359,6 +359,16 @@ pub trait UserRepository: Send + Sync {
handle: &str,
) -> Result<Option<Uuid>, DbError>;
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>;
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError>;
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError>;
async fn set_password_reset_code(
&self,
user_id: Uuid,
+7 -2
View File
@@ -18,6 +18,8 @@ use uuid::Uuid;
use super::user::map_sqlx_error;
const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600;
fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, DbError> {
serde_json::to_value(value).map_err(|e| {
tracing::error!("JSON serialization error: {}", e);
@@ -462,15 +464,18 @@ impl OAuthRepository for PostgresOAuthRepository {
did: &Did,
device_id: Option<&DeviceId>,
) -> Result<(), DbError> {
let extended_expiry =
chrono::Utc::now() + chrono::Duration::seconds(REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS);
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET did = $2, device_id = $3
SET did = $2, device_id = $3, expires_at = $4
WHERE id = $1
"#,
request_id.as_str(),
did.as_str(),
device_id.map(|d| d.as_str())
device_id.map(|d| d.as_str()),
extended_expiry
)
.execute(&self.pool)
.await
+54 -16
View File
@@ -549,7 +549,7 @@ impl UserRepository for PostgresUserRepository {
identifier: &str,
) -> Result<Option<bool>, DbError> {
let row = sqlx::query_scalar!(
"SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
"SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1",
identifier
)
.fetch_optional(&self.pool)
@@ -717,26 +717,15 @@ impl UserRepository for PostgresUserRepository {
}
async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result<bool, DbError> {
let result = sqlx::query!(
sqlx::query!(
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
email,
user_id
)
.execute(&self.pool)
.await;
match result {
Ok(_) => Ok(true),
Err(e) => {
if e.as_database_error()
.map(|db| db.is_unique_violation())
.unwrap_or(false)
{
Ok(false)
} else {
Err(map_sqlx_error(e))
}
}
}
.await
.map_err(map_sqlx_error)?;
Ok(true)
}
async fn verify_discord_channel(&self, user_id: Uuid, discord_id: &str) -> Result<(), DbError> {
@@ -1593,6 +1582,55 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)
}
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError> {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL",
email
)
.fetch_one(&self.pool)
.await
.map(|c| c.unwrap_or(0))
.map_err(map_sqlx_error)
}
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError> {
let query = match channel {
CommsChannel::Email => {
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Discord => {
"SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL"
}
CommsChannel::Telegram => {
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Signal => {
"SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL"
}
};
sqlx::query_scalar(query)
.bind(identifier)
.fetch_one(&self.pool)
.await
.map(|c: Option<i64>| c.unwrap_or(0))
.map_err(map_sqlx_error)
}
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
sqlx::query_scalar!(
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
email
)
.fetch_all(&self.pool)
.await
.map(|handles| handles.into_iter().map(Handle::from).collect())
.map_err(map_sqlx_error)
}
async fn set_password_reset_code(
&self,
user_id: Uuid,
+2
View File
@@ -94,6 +94,7 @@ pub struct AuthorizationRequestParameters {
pub response_mode: Option<String>,
pub login_hint: Option<String>,
pub dpop_jkt: Option<String>,
pub prompt: Option<String>,
#[serde(flatten)]
pub extra: Option<JsonValue>,
}
@@ -423,6 +424,7 @@ mod tests {
response_mode: None,
login_hint: None,
dpop_jkt: None,
prompt: None,
extra: None,
},
expires_at: Utc::now() + expires_in,
@@ -234,19 +234,6 @@ pub async fn update_notification_prefs(
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) {
info!(did = %user.did, "Email unchanged, skipping");
} else {
match state
.user_repo
.check_email_exists(&email_clean, user_id)
.await
{
Ok(true) => return ApiError::EmailTaken.into_response(),
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e)))
.into_response();
}
Ok(false) => {}
}
if let Err(e) = request_channel_verification(
&state,
user_id,
@@ -1,6 +1,6 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuth;
use crate::auth::{BearerAuth, generate_app_password};
use crate::delegation::{DelegationActionType, intersect_scopes};
use crate::state::{AppState, RateLimitKind};
use axum::{
@@ -154,17 +154,7 @@ pub async fn create_app_password(
(input.scopes.clone(), None)
};
let password: String = (0..4)
.map(|_| {
use rand::Rng;
let mut rng = rand::thread_rng();
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect();
(0..4)
.map(|_| chars[rng.gen_range(0..chars.len())])
.collect::<String>()
})
.collect::<Vec<String>>()
.join("-");
let password = generate_app_password();
let password_clone = password.clone();
let password_hash = match tokio::task::spawn_blocking(move || {
+116 -30
View File
@@ -14,6 +14,7 @@ use sha2::{Digest, Sha256};
use std::time::Duration;
use subtle::ConstantTimeEq;
use tracing::{error, info, warn};
use tranquil_db_traits::CommsChannel;
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
@@ -92,22 +93,23 @@ pub async fn request_email_update(
let formatted_code = crate::auth::verification_token::format_token_for_display(&code);
if let Some(Json(ref inp)) = input
&& let Some(ref new_email) = inp.new_email {
let new_email = new_email.trim().to_lowercase();
if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) {
let pending = PendingEmailUpdate {
new_email,
token_hash: hash_token(&code),
authorized: false,
};
if let Ok(json) = serde_json::to_string(&pending) {
let cache_key = email_update_cache_key(&auth.0.did);
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to cache pending email update: {:?}", e);
}
&& let Some(ref new_email) = inp.new_email
{
let new_email = new_email.trim().to_lowercase();
if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) {
let pending = PendingEmailUpdate {
new_email,
token_hash: hash_token(&code),
authorized: false,
};
if let Ok(json) = serde_json::to_string(&pending) {
let cache_key = email_update_cache_key(&auth.0.did);
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to cache pending email update: {:?}", e);
}
}
}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token(
@@ -278,11 +280,13 @@ pub async fn update_email(
let cache_key = email_update_cache_key(did);
if let Some(pending_json) = state.cache.get(&cache_key).await
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
&& pending.authorized && pending.new_email == new_email {
authorized_via_link = true;
let _ = state.cache.delete(&cache_key).await;
info!(did = %did, "Email update completed via link authorization");
}
&& pending.authorized
&& pending.new_email == new_email
{
authorized_via_link = true;
let _ = state.cache.delete(&cache_key).await;
info!(did = %did, "Email update completed via link authorization");
}
if !authorized_via_link {
let Some(ref t) = input.token else {
@@ -318,14 +322,6 @@ pub async fn update_email(
}
}
if let Ok(true) = state
.user_repo
.check_email_exists(&new_email, user_id)
.await
{
return ApiError::InvalidRequest("Email is already in use".into()).into_response();
}
if let Err(e) = state.user_repo.update_email(user_id, &new_email).await {
error!("DB error updating email: {:?}", e);
return ApiError::InternalError(None).into_response();
@@ -481,10 +477,11 @@ pub async fn authorize_email_update(
pending.authorized = true;
if let Ok(json) = serde_json::to_string(&pending)
&& let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to update pending email authorization: {:?}", e);
return ApiError::InternalError(None).into_response();
}
&& let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await
{
warn!("Failed to update pending email authorization: {:?}", e);
return ApiError::InternalError(None).into_response();
}
info!(did = %did, "Email update authorized via link click");
@@ -541,3 +538,92 @@ pub async fn check_email_update_status(
}))
.into_response()
}
#[derive(Deserialize)]
pub struct CheckEmailInUseInput {
pub email: String,
}
pub async fn check_email_in_use(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<CheckEmailInUseInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let email = input.email.trim().to_lowercase();
if email.is_empty() {
return ApiError::InvalidRequest("email is required".into()).into_response();
}
let count = match state.user_repo.count_accounts_by_email(&email).await {
Ok(c) => c,
Err(e) => {
error!("DB error checking email usage: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
Json(json!({
"inUse": count > 0,
}))
.into_response()
}
#[derive(Deserialize)]
pub struct CheckCommsChannelInUseInput {
pub channel: String,
pub identifier: String,
}
pub async fn check_comms_channel_in_use(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<CheckCommsChannelInUseInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let channel = match input.channel.to_lowercase().as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return ApiError::InvalidRequest("invalid channel".into()).into_response();
}
};
let identifier = input.identifier.trim();
if identifier.is_empty() {
return ApiError::InvalidRequest("identifier is required".into()).into_response();
}
let count = match state
.user_repo
.count_accounts_by_comms_identifier(channel, identifier)
.await
{
Ok(c) => c,
Err(e) => {
error!("DB error checking comms channel usage: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
Json(json!({
"inUse": count > 0,
}))
.into_response()
}
+3 -2
View File
@@ -23,8 +23,9 @@ pub use account_status::{
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{
authorize_email_update, check_email_update_status, check_email_verified, confirm_email,
request_email_update, update_email,
authorize_email_update, check_comms_channel_in_use, check_email_in_use,
check_email_update_status, check_email_verified, confirm_email, request_email_update,
update_email,
};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
@@ -18,7 +18,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, is_service_token};
use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
use crate::validation::validate_password;
@@ -52,19 +52,6 @@ fn generate_setup_token() -> String {
.collect()
}
fn generate_app_password() -> String {
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut rng = rand::thread_rng();
let segments: Vec<String> = (0..4)
.map(|_| {
(0..4)
.map(|_| chars[rng.gen_range(0..chars.len())] as char)
.collect()
})
.collect();
segments.join("-")
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePasskeyAccountInput {
+23 -2
View File
@@ -60,11 +60,22 @@ pub async fn request_password_reset(
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
let normalized = identifier.to_lowercase();
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
let is_email_lookup = normalized.contains('@');
let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
normalized.to_string()
} else {
format!("{}.{}", normalized, hostname_for_handles)
};
let multiple_accounts_warning = if is_email_lookup {
match state.user_repo.count_accounts_by_email(normalized).await {
Ok(count) if count > 1 => Some(count),
_ => None,
}
} else {
None
};
let user_id = match state
.user_repo
.get_id_by_email_or_handle(normalized, &normalized_handle)
@@ -73,7 +84,7 @@ pub async fn request_password_reset(
Ok(Some(id)) => id,
Ok(None) => {
info!("Password reset requested for unknown identifier");
return EmptyResponse::ok().into_response();
return Json(serde_json::json!({ "success": true })).into_response();
}
Err(e) => {
error!("DB error in request_password_reset: {:?}", e);
@@ -103,7 +114,17 @@ pub async fn request_password_reset(
warn!("Failed to enqueue password reset notification: {:?}", e);
}
info!("Password reset requested for user {}", user_id);
EmptyResponse::ok().into_response()
match multiple_accounts_warning {
Some(count) => Json(serde_json::json!({
"success": true,
"multipleAccounts": true,
"accountCount": count,
"message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account."
}))
.into_response(),
None => Json(serde_json::json!({ "success": true })).into_response(),
}
}
#[derive(Deserialize)]
+3 -1
View File
@@ -84,7 +84,9 @@ pub async fn reauth_password(
let app_password_valid = app_password_hashes
.iter()
.any(|h| bcrypt::verify(&input.password, h).unwrap_or(false));
.fold(false, |acc, h| {
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
});
if !app_password_valid {
warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
+10
View File
@@ -44,6 +44,16 @@ pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, St
crate::config::decrypt_key(encrypted, Some(version))
}
pub fn generate_app_password() -> String {
use rand::Rng;
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut rng = rand::thread_rng();
let segments: Vec<String> = (0..4)
.map(|_| (0..4).map(|_| chars[rng.gen_range(0..chars.len())] as char).collect())
.collect();
segments.join("-")
}
const KEY_CACHE_TTL_SECS: u64 = 300;
const SESSION_CACHE_TTL_SECS: u64 = 60;
const USER_STATUS_CACHE_TTL_SECS: u64 = 60;
+12
View File
@@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router {
"/_account.checkEmailUpdateStatus",
get(api::server::check_email_update_status),
)
.route(
"/_account.checkEmailInUse",
post(api::server::check_email_in_use),
)
.route(
"/_account.checkCommsChannelInUse",
post(api::server::check_comms_channel_in_use),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(api::server::reserve_signing_key),
@@ -556,6 +564,10 @@ pub fn app(state: AppState) -> Router {
.route("/passkey/start", post(oauth::endpoints::passkey_start))
.route("/passkey/finish", post(oauth::endpoints::passkey_finish))
.route("/authorize/deny", post(oauth::endpoints::authorize_deny))
.route(
"/register/complete",
post(oauth::endpoints::register_complete),
)
.route("/authorize/consent", get(oauth::endpoints::consent_get))
.route("/authorize/consent", post(oauth::endpoints::consent_post))
.route(
@@ -329,6 +329,13 @@ pub async fn authorize_get(
tracing::info!("No login_hint in request");
}
if request_data.parameters.prompt.as_deref() == Some("create") {
return redirect_see_other(&format!(
"/app/oauth/register?request_uri={}",
url_encode(&request_uri)
));
}
if !force_new_account
&& let Some(device_id) = extract_device_cookie(&headers)
&& let Ok(accounts) = state
@@ -3208,3 +3215,308 @@ pub async fn authorize_passkey_finish(
)
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct RegisterCompleteInput {
pub request_uri: String,
pub did: String,
pub app_password: String,
}
pub async fn register_complete(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<RegisterCompleteInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::OAuthRegisterComplete, &client_ip)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "RateLimitExceeded",
"error_description": "Too many attempts. Please try again later."
})),
)
.into_response();
}
let did = Did::from(form.did.clone());
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.oauth_repo
.get_authorization_request(&request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(
request_uri = %form.request_uri,
error = ?e,
"register_complete: failed to fetch authorization request"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.oauth_repo
.delete_authorization_request(&request_id)
.await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
if request_data.parameters.prompt.as_deref() != Some("create") {
tracing::warn!(
request_uri = %form.request_uri,
prompt = ?request_data.parameters.prompt,
"register_complete called on non-registration OAuth flow"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "This endpoint is only for registration flows."
})),
)
.into_response();
}
if request_data.code.is_some() {
tracing::warn!(
request_uri = %form.request_uri,
"register_complete called on already-completed OAuth flow"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization has already been completed."
})),
)
.into_response();
}
if let Some(existing_did) = &request_data.did
&& existing_did != &form.did
{
tracing::warn!(
request_uri = %form.request_uri,
existing_did = %existing_did,
attempted_did = %form.did,
"register_complete attempted with different DID than already bound"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request is already bound to a different account."
})),
)
.into_response();
}
let password_hashes = match state
.session_repo
.get_app_password_hashes_by_did(&did)
.await
{
Ok(hashes) => hashes,
Err(e) => {
tracing::error!(
did = %did,
error = ?e,
"register_complete: failed to fetch app password hashes"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let password_valid = password_hashes
.iter()
.fold(false, |acc, hash| {
acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false)
});
if !password_valid {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Invalid credentials."
})),
)
.into_response();
}
let is_verified = match state.user_repo.get_session_info_by_did(&did).await {
Ok(Some(info)) => {
info.email_verified
|| info.discord_verified
|| info.telegram_verified
|| info.signal_verified
}
Ok(None) => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Account not found."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(
did = %did,
error = ?e,
"register_complete: failed to fetch session info"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if !is_verified {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Please verify your account before continuing."
})),
)
.into_response();
}
if let Err(e) = state
.oauth_repo
.set_authorization_did(&request_id, &did, None)
.await
{
tracing::error!(
request_uri = %form.request_uri,
did = %did,
error = ?e,
"register_complete: failed to set authorization DID"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.oauth_repo.as_ref(),
&did,
&client_id_typed,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
tracing::info!(
did = %did,
client_id = %request_data.parameters.client_id,
"OAuth registration complete, redirecting to consent"
);
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let auth_code = AuthorizationCode::from(code.0.clone());
if let Err(e) = state
.oauth_repo
.update_authorization_request(&request_id, &did, None, &auth_code)
.await
{
tracing::error!(
request_uri = %form.request_uri,
did = %did,
error = ?e,
"register_complete: failed to update authorization request with code"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
tracing::info!(
did = %did,
client_id = %request_data.parameters.client_id,
"OAuth registration flow completed successfully"
);
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
@@ -50,6 +50,8 @@ pub struct AuthorizationServerMetadata {
pub introspection_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id_metadata_document_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_values_supported: Option<Vec<String>>,
}
pub async fn oauth_protected_resource(
@@ -113,6 +115,13 @@ pub async fn oauth_authorization_server(
revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)),
introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)),
client_id_metadata_document_supported: Some(true),
prompt_values_supported: Some(vec![
"none".to_string(),
"login".to_string(),
"consent".to_string(),
"select_account".to_string(),
"create".to_string(),
]),
})
}
@@ -37,6 +37,8 @@ pub struct ParRequest {
pub client_assertion: Option<String>,
#[serde(default)]
pub client_assertion_type: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -109,6 +111,7 @@ pub async fn pushed_authorization_request(
)));
}
};
let prompt = validate_prompt(&request.prompt)?;
let parameters = AuthorizationRequestParameters {
response_type: request.response_type,
client_id: request.client_id.clone(),
@@ -120,6 +123,7 @@ pub async fn pushed_authorization_request(
response_mode,
login_hint: request.login_hint,
dpop_jkt: request.dpop_jkt,
prompt,
extra: None,
};
let request_data = RequestData {
@@ -261,3 +265,22 @@ fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
false
}
fn validate_prompt(prompt: &Option<String>) -> Result<Option<String>, OAuthError> {
const VALID_PROMPTS: &[&str] = &["none", "login", "consent", "select_account", "create"];
match prompt {
None => Ok(None),
Some(p) if p.is_empty() => Ok(None),
Some(p) => {
if VALID_PROMPTS.contains(&p.as_str()) {
Ok(Some(p.clone()))
} else {
Err(OAuthError::InvalidRequest(format!(
"Unsupported prompt value: {}",
p
)))
}
}
}
}
+6
View File
@@ -36,6 +36,7 @@ pub struct RateLimiters {
pub sso_initiate: Arc<KeyedRateLimiter>,
pub sso_callback: Arc<KeyedRateLimiter>,
pub sso_unlink: Arc<KeyedRateLimiter>,
pub oauth_register_complete: Arc<KeyedRateLimiter>,
}
impl Default for RateLimiters {
@@ -107,6 +108,11 @@ impl RateLimiters {
sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
oauth_register_complete: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
)),
}
}
+132 -45
View File
@@ -12,7 +12,7 @@ use tranquil_types::RequestId;
use super::config::SsoConfig;
use crate::api::error::ApiError;
use crate::auth::extractor::extract_bearer_token_from_header;
use crate::auth::validate_bearer_token_cached;
use crate::auth::{generate_app_password, validate_bearer_token_cached};
use crate::rate_limit::extract_client_ip;
use crate::state::{AppState, RateLimitKind};
@@ -87,13 +87,15 @@ pub async fn sso_initiate(
return Err(ApiError::SsoProviderNotFound);
}
if let Some(ref uri) = input.request_uri
&& uri.len() > 500 {
return Err(ApiError::InvalidRequest("Request URI too long".into()));
}
&& uri.len() > 500
{
return Err(ApiError::InvalidRequest("Request URI too long".into()));
}
if let Some(ref action) = input.action
&& action.len() > 20 {
return Err(ApiError::SsoInvalidAction);
}
&& action.len() > 20
{
return Err(ApiError::SsoInvalidAction);
}
let provider_type =
SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?;
@@ -426,6 +428,35 @@ async fn handle_sso_login(
}
};
let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await {
Ok(Some(info)) => {
info.email_verified
|| info.discord_verified
|| info.telegram_verified
|| info.signal_verified
}
Ok(None) => {
tracing::error!("User not found for SSO login: {}", identity.did);
return redirect_to_error("Account not found");
}
Err(e) => {
tracing::error!("Database error checking verification status: {:?}", e);
return redirect_to_error("Database error");
}
};
if !is_verified {
tracing::warn!(
did = %identity.did,
provider = %provider.as_str(),
"SSO login attempt for unverified account"
);
return redirect_to_login_with_error(
request_uri,
"Please verify your account before logging in",
);
}
if let Err(e) = state
.sso_repo
.update_external_identity_login(
@@ -813,6 +844,8 @@ pub struct CompleteRegistrationInput {
pub discord_id: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub did_type: Option<String>,
pub did: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -825,6 +858,10 @@ pub struct CompleteRegistrationResponse {
pub access_jwt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_jwt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_password_name: Option<String>,
}
pub async fn complete_registration(
@@ -914,14 +951,6 @@ pub async fn complete_registration(
if !crate::api::validation::is_valid_email(e) {
return Err(ApiError::InvalidEmail);
}
let email_exists = state
.user_repo
.check_email_exists(e, uuid::Uuid::nil())
.await
.unwrap_or(true);
if email_exists {
return Err(ApiError::EmailTaken);
}
Some(e.clone())
}
None => None,
@@ -967,37 +996,66 @@ pub async fn complete_registration(
};
let pds_endpoint = format!("https://{}", hostname);
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let did_type = input.did_type.as_deref().unwrap_or("plc");
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
&rotation_key,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
tracing::error!("Error creating PLC genesis operation: {:?}", e);
return Err(ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
)));
let did = match did_type {
"web" => {
let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
self_hosted_did
}
"web-external" => {
let d = match &input.did {
Some(d) if !d.trim().is_empty() => d.trim(),
_ => {
return Err(ApiError::InvalidRequest(
"External did:web requires the 'did' field to be provided".into(),
));
}
};
if !d.starts_with("did:web:") {
return Err(ApiError::InvalidDid(
"External DID must be a did:web".into(),
));
}
tracing::info!(did = %d, "Creating external did:web SSO account");
d.to_string()
}
_ => {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
&rotation_key,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
tracing::error!("Error creating PLC genesis operation: {:?}", e);
return Err(ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
)));
}
};
let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone()));
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
tracing::error!("Failed to submit PLC genesis operation: {:?}", e);
return Err(ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
)));
}
genesis_result.did
}
};
let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone()));
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
tracing::error!("Failed to submit PLC genesis operation: {:?}", e);
return Err(ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
)));
}
let did = genesis_result.did;
tracing::info!(did = %did, handle = %handle, provider = %pending_preview.provider.as_str(), "Created DID for SSO account");
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
@@ -1093,7 +1151,7 @@ pub async fn complete_registration(
pending_registration_token: input.token.clone(),
};
let _create_result = match state.user_repo.create_sso_account(&create_input).await {
let create_result = match state.user_repo.create_sso_account(&create_input).await {
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Err(ApiError::HandleNotAvailable(None));
@@ -1145,6 +1203,28 @@ pub async fn complete_registration(
tracing::warn!("Failed to create default profile for {}: {}", did, e);
}
let app_password = generate_app_password();
let app_password_name = "bsky.app".to_string();
let app_password_hash = match bcrypt::hash(&app_password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash app password: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let app_password_data = tranquil_db_traits::AppPasswordCreate {
user_id: create_result.user_id,
name: app_password_name.clone(),
password_hash: app_password_hash,
privileged: false,
scopes: None,
created_by_controller_did: None,
};
if let Err(e) = state.session_repo.create_app_password(&app_password_data).await {
tracing::warn!("Failed to create initial app password: {:?}", e);
}
let is_standalone = pending_preview.request_uri == "standalone";
if !is_standalone {
@@ -1250,6 +1330,8 @@ pub async fn complete_registration(
redirect_url: "/app/dashboard".to_string(),
access_jwt: Some(access_meta.token),
refresh_jwt: Some(refresh_meta.token),
app_password: Some(app_password),
app_password_name: Some(app_password_name),
}));
}
@@ -1262,6 +1344,8 @@ pub async fn complete_registration(
),
access_jwt: None,
refresh_jwt: None,
app_password: Some(app_password),
app_password_name: Some(app_password_name),
}));
}
@@ -1291,7 +1375,8 @@ pub async fn complete_registration(
format!("/app/verify?did={}", urlencoding::encode(&did))
} else {
format!(
"/app/oauth/verify?request_uri={}",
"/app/verify?did={}&request_uri={}",
urlencoding::encode(&did),
urlencoding::encode(&pending_preview.request_uri)
)
};
@@ -1302,5 +1387,7 @@ pub async fn complete_registration(
redirect_url,
access_jwt: None,
refresh_jwt: None,
app_password: Some(app_password),
app_password_name: Some(app_password_name),
}))
}
+15 -12
View File
@@ -833,9 +833,10 @@ impl AppleProvider {
{
let cache = self.client_secret_cache.read().await;
if let Some(ref cached) = *cache
&& cached.expires_at > now + 3600 {
return Ok(cached.secret.clone());
}
&& cached.expires_at > now + 3600
{
return Ok(cached.secret.clone());
}
}
let (secret, expires_at) = self.generate_client_secret()?;
@@ -1069,15 +1070,16 @@ impl SsoManager {
cfg,
Some("https://accounts.google.com"),
"Google",
) {
providers.insert(SsoProviderType::Google, Arc::new(provider));
}
)
{
providers.insert(SsoProviderType::Google, Arc::new(provider));
}
if let Some(ref cfg) = config.gitlab
&& let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab")
{
providers.insert(SsoProviderType::Gitlab, Arc::new(provider));
}
{
providers.insert(SsoProviderType::Gitlab, Arc::new(provider));
}
if let Some(ref cfg) = config.oidc
&& let Some(provider) = OidcProvider::new(
@@ -1085,9 +1087,10 @@ impl SsoManager {
cfg,
None,
cfg.display_name.as_deref().unwrap_or("SSO"),
) {
providers.insert(SsoProviderType::Oidc, Arc::new(provider));
}
)
{
providers.insert(SsoProviderType::Oidc, Arc::new(provider));
}
if let Some(ref cfg) = config.apple {
match AppleProvider::new(cfg) {
+4
View File
@@ -62,6 +62,7 @@ pub enum RateLimitKind {
SsoInitiate,
SsoCallback,
SsoUnlink,
OAuthRegisterComplete,
}
impl RateLimitKind {
@@ -85,6 +86,7 @@ impl RateLimitKind {
Self::SsoInitiate => "sso_initiate",
Self::SsoCallback => "sso_callback",
Self::SsoUnlink => "sso_unlink",
Self::OAuthRegisterComplete => "oauth_register_complete",
}
}
@@ -108,6 +110,7 @@ impl RateLimitKind {
Self::SsoInitiate => (10, 60_000),
Self::SsoCallback => (30, 60_000),
Self::SsoUnlink => (10, 60_000),
Self::OAuthRegisterComplete => (5, 300_000),
}
}
}
@@ -251,6 +254,7 @@ impl AppState {
RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate,
RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback,
RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink,
RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete,
};
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+12 -9
View File
@@ -463,7 +463,7 @@ async fn test_unverified_account_can_update_email_without_token() {
}
#[tokio::test]
async fn test_update_email_taken_by_another_user() {
async fn test_update_email_to_same_as_another_user_allowed() {
let client = common::client();
let base_url = common::base_url().await;
let pool = common::get_test_db_pool().await;
@@ -499,13 +499,16 @@ async fn test_update_email_taken_by_another_user() {
.send()
.await
.expect("Failed to update email");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "InvalidRequest");
assert!(
body["message"]
.as_str()
.unwrap_or("")
.contains("already in use")
assert_eq!(
res.status(),
StatusCode::OK,
"Multiple accounts can share the same email address"
);
let user_email: Option<String> =
sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did2)
.fetch_one(pool)
.await
.expect("User not found");
assert_eq!(user_email, Some(email1.clone()));
}
+409
View File
@@ -1237,3 +1237,412 @@ async fn test_granular_scope_rpc_specific_method() {
"Should require lxm parameter for granular scopes"
);
}
#[tokio::test]
async fn test_oauth_metadata_includes_prompt_values_supported() {
let url = base_url().await;
let client = client();
let as_res = client
.get(format!("{}/.well-known/oauth-authorization-server", url))
.send()
.await
.unwrap();
assert_eq!(as_res.status(), StatusCode::OK);
let as_body: Value = as_res.json().await.unwrap();
let prompt_values = as_body["prompt_values_supported"]
.as_array()
.expect("prompt_values_supported should be an array");
assert!(
prompt_values.contains(&json!("none")),
"Should support prompt=none"
);
assert!(
prompt_values.contains(&json!("login")),
"Should support prompt=login"
);
assert!(
prompt_values.contains(&json!("consent")),
"Should support prompt=consent"
);
assert!(
prompt_values.contains(&json!("select_account")),
"Should support prompt=select_account"
);
assert!(
prompt_values.contains(&json!("create")),
"Should support prompt=create"
);
}
#[tokio::test]
async fn test_par_accepts_valid_prompt_values() {
let url = base_url().await;
let client = client();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let valid_prompts = ["none", "login", "consent", "select_account", "create"];
for prompt in valid_prompts {
let par_res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", "test-state"),
("prompt", prompt),
])
.send()
.await
.unwrap();
assert_eq!(
par_res.status(),
StatusCode::CREATED,
"PAR should accept prompt={}",
prompt
);
}
}
#[tokio::test]
async fn test_par_rejects_invalid_prompt_value() {
let url = base_url().await;
let client = client();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", "test-state"),
("prompt", "invalid_prompt"),
])
.send()
.await
.unwrap();
assert_eq!(
par_res.status(),
StatusCode::BAD_REQUEST,
"PAR should reject invalid prompt value"
);
let body: Value = par_res.json().await.unwrap();
assert_eq!(body["error"], "invalid_request");
assert!(
body["error_description"]
.as_str()
.unwrap_or("")
.contains("prompt"),
"Error should mention prompt"
);
}
#[tokio::test]
async fn test_prompt_create_redirects_to_register() {
let url = base_url().await;
let client = no_redirect_client();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_res = reqwest::Client::new()
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", "test-state"),
("prompt", "create"),
])
.send()
.await
.unwrap();
assert_eq!(par_res.status(), StatusCode::CREATED);
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = client
.get(format!("{}/oauth/authorize", url))
.query(&[("request_uri", request_uri)])
.send()
.await
.unwrap();
assert!(
auth_res.status().is_redirection(),
"Should redirect when prompt=create"
);
let location = auth_res
.headers()
.get("location")
.expect("Should have Location header")
.to_str()
.unwrap();
assert!(
location.contains("/app/oauth/register"),
"Should redirect to /app/oauth/register, got: {}",
location
);
assert!(
location.contains("request_uri="),
"Should include request_uri in redirect"
);
}
#[tokio::test]
async fn test_register_complete_rejects_invalid_request_uri() {
let url = base_url().await;
let client = client();
let res = client
.post(format!("{}/oauth/register/complete", url))
.json(&json!({
"request_uri": "urn:ietf:params:oauth:request_uri:nonexistent",
"did": "did:plc:test123",
"app_password": "test-password"
}))
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"Should reject invalid request_uri"
);
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"], "invalid_request");
}
#[tokio::test]
async fn test_register_complete_rejects_wrong_credentials() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("rc{}", suffix);
let email = format!("rc{}@example.com", suffix);
let password = "Regcomplete123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
verify_new_account(&http_client, user_did).await;
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_res = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", "test-state"),
("prompt", "create"),
])
.send()
.await
.unwrap();
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let res = http_client
.post(format!("{}/oauth/register/complete", url))
.json(&json!({
"request_uri": request_uri,
"did": user_did,
"app_password": "wrong-password"
}))
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::FORBIDDEN,
"Should reject wrong credentials"
);
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"], "access_denied");
}
#[tokio::test]
async fn test_full_oauth_registration_flow() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("oauthreg{}", suffix);
let email = format!("oauthreg{}@example.com", suffix);
let password = "OauthRegTest123!";
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let state = format!("state-{}", suffix);
let par_res = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
("state", &state),
("prompt", "create"),
])
.send()
.await
.unwrap();
assert_eq!(
par_res.status(),
StatusCode::CREATED,
"PAR with prompt=create should succeed"
);
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
.send()
.await
.unwrap();
assert_eq!(
create_res.status(),
StatusCode::OK,
"Account creation should succeed"
);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let access_jwt = account["accessJwt"].as_str().unwrap();
let app_password_res = http_client
.post(format!(
"{}/xrpc/com.atproto.server.createAppPassword",
url
))
.header("Authorization", format!("Bearer {}", access_jwt))
.json(&json!({ "name": "oauth-test-app" }))
.send()
.await
.unwrap();
assert_eq!(
app_password_res.status(),
StatusCode::OK,
"App password creation should succeed"
);
let app_password_body: Value = app_password_res.json().await.unwrap();
let app_password = app_password_body["password"].as_str().unwrap();
verify_new_account(&http_client, user_did).await;
let complete_res = http_client
.post(format!("{}/oauth/register/complete", url))
.json(&json!({
"request_uri": request_uri,
"did": user_did,
"app_password": app_password
}))
.send()
.await
.unwrap();
assert_eq!(
complete_res.status(),
StatusCode::OK,
"register_complete should succeed"
);
let complete_body: Value = complete_res.json().await.unwrap();
let mut redirect_location = complete_body["redirect_uri"]
.as_str()
.expect("Expected redirect_uri from register_complete")
.to_string();
if redirect_location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
assert_eq!(
consent_res.status(),
StatusCode::OK,
"Consent should succeed"
);
let consent_body: Value = consent_res.json().await.unwrap();
redirect_location = consent_body["redirect_uri"]
.as_str()
.expect("Expected redirect_uri from consent")
.to_string();
}
assert!(
redirect_location.contains("code="),
"Should have authorization code in redirect: {}",
redirect_location
);
let code = redirect_location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(
token_res.status(),
StatusCode::OK,
"Token exchange should succeed"
);
let token_body: Value = token_res.json().await.unwrap();
assert!(
token_body["access_token"].is_string(),
"Should have access_token"
);
assert!(
token_body["refresh_token"].is_string(),
"Should have refresh_token"
);
assert_eq!(token_body["token_type"], "Bearer");
assert_eq!(
token_body["sub"], user_did,
"Token sub should match user DID"
);
}
+1 -1
View File
@@ -1039,7 +1039,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
let redirect_url = body["redirectUrl"].as_str().unwrap();
assert!(
redirect_url.contains("/app/oauth/verify"),
redirect_url.contains("/app/verify"),
"Non-auto-verified channel should redirect to verify, got: {}",
redirect_url
);
+9 -3
View File
@@ -439,6 +439,7 @@
<div class="actions" id="heroActions">
<a href="/app/register" class="btn primary" id="heroPrimary"
>Join This Server</a>
<a href="/app/login" class="btn secondary" id="heroLogin">Login</a>
<a
href="https://tangled.org/tranquil.farm/tranquil-pds"
class="btn secondary"
@@ -461,9 +462,9 @@
<div class="feature">
<h3>Real security</h3>
<p>
Sign in with passkeys or SSO, add two-factor authentication,
set up backup codes, and mark devices you trust. Your account
stays yours.
Sign in with passkeys or SSO, add two-factor authentication, set
up backup codes, and mark devices you trust. Your account stays
yours.
</p>
</div>
@@ -545,6 +546,7 @@
<div class="actions" id="footerActions">
<a href="/app/register" class="btn primary" id="footerPrimary"
>Join This Server</a>
<a href="/app/login" class="btn secondary" id="footerLogin">Login</a>
<a
href="https://tangled.org/tranquil.farm/tranquil-pds"
class="btn secondary"
@@ -585,6 +587,10 @@
footerPrimary.href = "/app/dashboard";
footerPrimary.textContent = handle;
}
var heroLogin = document.getElementById("heroLogin");
var footerLogin = document.getElementById("footerLogin");
if (heroLogin) heroLogin.classList.add("hidden");
if (footerLogin) footerLogin.classList.add("hidden");
if (heroSecondary) {
heroSecondary.classList.add("hidden");
}
+12 -10
View File
@@ -6,8 +6,6 @@
import { isLoading as i18nLoading } from 'svelte-i18n'
import Toast from './components/Toast.svelte'
import Login from './routes/Login.svelte'
import Register from './routes/Register.svelte'
import RegisterPasskey from './routes/RegisterPasskey.svelte'
import RegisterSso from './routes/RegisterSso.svelte'
import Verify from './routes/Verify.svelte'
import ResetPassword from './routes/ResetPassword.svelte'
@@ -29,7 +27,9 @@
import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthDelegation from './routes/OAuthDelegation.svelte'
import OAuthError from './routes/OAuthError.svelte'
import OAuthSsoRegister from './routes/OAuthSsoRegister.svelte'
import SsoRegisterComplete from './routes/SsoRegisterComplete.svelte'
import Register from './routes/Register.svelte'
import RegisterPassword from './routes/RegisterPassword.svelte'
import Security from './routes/Security.svelte'
import TrustedDevices from './routes/TrustedDevices.svelte'
import Controllers from './routes/Controllers.svelte'
@@ -98,12 +98,6 @@
switch (path) {
case '/login':
return Login
case '/register':
return RegisterPasskey
case '/register-password':
return Register
case '/register-sso':
return RegisterSso
case '/verify':
return Verify
case '/reset-password':
@@ -145,7 +139,14 @@
case '/oauth/error':
return OAuthError
case '/oauth/sso-register':
return OAuthSsoRegister
return SsoRegisterComplete
case '/register':
case '/oauth/register':
return Register
case '/oauth/register-sso':
return RegisterSso
case '/oauth/register-password':
return RegisterPassword
case '/security':
return Security
case '/trusted-devices':
@@ -167,6 +168,7 @@
let currentPath = $derived(getCurrentPath())
let CurrentComponent = $derived(getComponent(currentPath))
</script>
<main>
@@ -6,20 +6,30 @@
interface Props {
active: 'passkey' | 'password' | 'sso'
ssoAvailable?: boolean
oauthRequestUri?: string | null
}
let { active, ssoAvailable = true }: Props = $props()
let { active, ssoAvailable = true, oauthRequestUri = null }: Props = $props()
function buildOauthUrl(route: string): string {
const url = getFullUrl(route)
return oauthRequestUri ? `${url}?request_uri=${encodeURIComponent(oauthRequestUri)}` : url
}
const passkeyUrl = $derived(buildOauthUrl(routes.oauthRegister))
const passwordUrl = $derived(buildOauthUrl(routes.oauthRegisterPassword))
const ssoUrl = $derived(buildOauthUrl(routes.oauthRegisterSso))
</script>
<div class="account-type-switcher">
<a href={getFullUrl(routes.register)} class="switcher-option" class:active={active === 'passkey'}>
<a href={passkeyUrl} class="switcher-option" class:active={active === 'passkey'}>
{$_('register.passkeyAccount')}
</a>
<a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}>
<a href={passwordUrl} class="switcher-option" class:active={active === 'password'}>
{$_('register.passwordAccount')}
</a>
{#if ssoAvailable || active === 'sso'}
<a href={getFullUrl(routes.registerSso)} class="switcher-option" class:active={active === 'sso'}>
<a href={ssoUrl} class="switcher-option" class:active={active === 'sso'}>
{$_('register.ssoAccount')}
</a>
{:else}
+30 -90
View File
@@ -186,7 +186,7 @@
<div class="modal-content">
{#if activeMethod === 'password'}
<form onsubmit={handlePasswordSubmit}>
<div class="form-group">
<div class="field">
<label for="reauth-password">{$_('reauth.password')}</label>
<input
id="reauth-password"
@@ -196,13 +196,13 @@
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn-primary" disabled={loading || !password}>
<button type="submit" disabled={loading || !password}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'totp'}
<form onsubmit={handleTotpSubmit}>
<div class="form-group">
<div class="field">
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
<input
id="reauth-totp"
@@ -215,18 +215,14 @@
maxlength="6"
/>
</div>
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
<button type="submit" disabled={loading || !totpCode}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'passkey'}
<div class="passkey-auth">
<p>{$_('reauth.passkeyPrompt')}</p>
<button
class="btn-primary"
onclick={handlePasskeyAuth}
disabled={loading}
>
<button onclick={handlePasskeyAuth} disabled={loading}>
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
</button>
</div>
@@ -234,7 +230,7 @@
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
<button class="secondary" onclick={handleClose} disabled={loading}>
{$_('reauth.cancel')}
</button>
</div>
@@ -246,18 +242,18 @@
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal);
}
.modal {
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 400px;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
max-width: var(--width-sm);
width: 90%;
max-height: 90vh;
overflow-y: auto;
@@ -267,19 +263,19 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-size: var(--text-lg);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
font-size: var(--text-xl);
cursor: pointer;
color: var(--text-secondary);
padding: 0;
@@ -291,36 +287,36 @@
}
.modal-description {
padding: 1rem 1.5rem 0;
padding: var(--space-4) var(--space-6) 0;
margin: 0;
color: var(--text-secondary);
}
.error-message {
margin: 1rem 1.5rem 0;
padding: 0.75rem;
margin: var(--space-4) var(--space-6) 0;
padding: var(--space-3);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
border-radius: var(--radius-md);
color: var(--error-text);
font-size: 0.875rem;
font-size: var(--text-sm);
}
.method-tabs {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem 0;
gap: var(--space-2);
padding: var(--space-4) var(--space-6) 0;
}
.tab {
flex: 1;
padding: 0.5rem 1rem;
padding: var(--space-2) var(--space-4);
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
font-size: var(--text-sm);
}
.tab:hover {
@@ -334,32 +330,11 @@
}
.modal-content {
padding: 1.5rem;
padding: var(--space-6);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
.modal-content .field {
margin-bottom: var(--space-4);
}
.passkey-auth {
@@ -367,52 +342,17 @@
}
.passkey-auth p {
margin-bottom: 1rem;
margin-bottom: var(--space-4);
color: var(--text-secondary);
}
.btn-primary {
.modal-content button:not(.tab) {
width: 100%;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-footer {
padding: 0 1.5rem 1.5rem;
padding: 0 var(--space-6) var(--space-6);
display: flex;
justify-content: flex-end;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.875rem;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
-4
View File
@@ -70,8 +70,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
-3
View File
@@ -69,7 +69,4 @@
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+17
View File
@@ -310,6 +310,23 @@ export const api = {
});
},
checkEmailInUse(email: string): Promise<{ inUse: boolean }> {
return xrpc("_account.checkEmailInUse", {
method: "POST",
body: { email },
});
},
checkCommsChannelInUse(
channel: "email" | "discord" | "telegram" | "signal",
identifier: string,
): Promise<{ inUse: boolean }> {
return xrpc("_account.checkCommsChannelInUse", {
method: "POST",
body: { channel, identifier },
});
},
async getSession(token: AccessToken): Promise<Session> {
const raw = await xrpc<unknown>("com.atproto.server.getSession", { token });
return castSession(raw);
@@ -915,6 +915,7 @@ export function createInboundMigrationFlow() {
authMethod: "password",
passkeySetupToken: null,
oauthCodeVerifier: null,
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
};
+7 -7
View File
@@ -1,15 +1,15 @@
export * from "./types";
export * from "./atproto-client";
export * from "./storage";
export * from "./blob-migration";
export * from "./types.ts";
export * from "./atproto-client.ts";
export * from "./storage.ts";
export * from "./blob-migration.ts";
export {
createInboundMigrationFlow,
type InboundMigrationFlow,
} from "./flow.svelte";
} from "./flow.svelte.ts";
export {
clearOfflineState,
createOfflineInboundMigrationFlow,
getOfflineResumeInfo,
hasPendingOfflineMigration,
} from "./offline-flow.svelte";
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
} from "./offline-flow.svelte.ts";
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte.ts";
+80 -3
View File
@@ -261,7 +261,10 @@ function extractDPoPNonceFromResponse(response: Response): void {
}
}
export async function startOAuthLogin(loginHint?: string): Promise<void> {
async function startOAuthFlow(options?: {
loginHint?: string;
prompt?: string;
}): Promise<void> {
clearAllOAuthState();
const state = generateState();
@@ -283,8 +286,11 @@ export async function startOAuthLogin(loginHint?: string): Promise<void> {
code_challenge_method: "S256",
dpop_jkt: dpopJkt,
};
if (loginHint) {
parParams.login_hint = loginHint;
if (options?.loginHint) {
parParams.login_hint = options.loginHint;
}
if (options?.prompt) {
parParams.prompt = options.prompt;
}
const parResponse = await fetch("/oauth/par", {
@@ -311,6 +317,77 @@ export async function startOAuthLogin(loginHint?: string): Promise<void> {
globalThis.location.href = authorizeUrl.toString();
}
export async function startOAuthLogin(loginHint?: string): Promise<void> {
return startOAuthFlow({ loginHint });
}
export async function startOAuthRegister(): Promise<void> {
return startOAuthFlow({ prompt: "create" });
}
export async function getOAuthRequestUri(prompt?: string): Promise<string> {
clearAllOAuthState();
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const keyPair = await getOrCreateDPoPKeyPair();
const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
saveOAuthState({ state, codeVerifier });
const parParams: Record<string, string> = {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: SCOPES,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
dpop_jkt: dpopJkt,
};
if (prompt) {
parParams.prompt = prompt;
}
const parResponse = await fetch("/oauth/par", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(parParams),
});
if (!parResponse.ok) {
const error = await parResponse.json().catch(() => ({
error: "Unknown error",
}));
throw new Error(
error.error_description || error.error || "Failed to get request URI",
);
}
const { request_uri } = await parResponse.json();
return request_uri;
}
export function getRequestUriFromUrl(): string | null {
const params = new URLSearchParams(globalThis.location.search);
return params.get("request_uri");
}
export async function ensureRequestUri(
prompt = "create",
): Promise<string | null> {
const existing = getRequestUriFromUrl();
if (existing) return existing;
const newRequestUri = await getOAuthRequestUri(prompt);
const url = new URL(globalThis.location.href);
url.searchParams.set("request_uri", newRequestUri);
globalThis.location.href = url.toString();
return null;
}
export interface OAuthTokens {
access_token: string;
refresh_token?: string;
@@ -49,73 +49,3 @@
</button>
</div>
<style>
.app-password-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.warning-box {
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-3);
color: var(--warning-text);
}
.warning-box p {
margin: 0;
color: var(--warning-text);
}
.app-password-display {
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.app-password-code {
display: block;
font-size: var(--text-xl);
font-family: ui-monospace, monospace;
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { api, ApiError } from '../api'
import { resendVerification } from '../auth.svelte'
import type { RegistrationFlow } from './flow.svelte'
@@ -13,6 +14,35 @@
let resending = $state(false)
let resendMessage = $state<string | null>(null)
let pollingInterval: ReturnType<typeof setInterval> | null = null
$effect(() => {
if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) {
pollingInterval = setInterval(async () => {
if (verificationCode.trim()) return
const advanced = await flow.checkAndAdvanceIfVerified()
if (advanced && pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}, 3000)
}
return () => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
})
onDestroy(() => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
})
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'email'
@@ -34,6 +34,10 @@ export interface RegistrationFlowState {
error: string | null;
submitting: boolean;
pdsHostname: string;
emailInUse: boolean;
discordInUse: boolean;
telegramInUse: boolean;
signalInUse: boolean;
}
export function createRegistrationFlow(
@@ -63,6 +67,10 @@ export function createRegistrationFlow(
error: null,
submitting: false,
pdsHostname,
emailInUse: false,
discordInUse: false,
telegramInUse: false,
signalInUse: false,
});
function getPdsEndpoint(): string {
@@ -105,6 +113,36 @@ export function createRegistrationFlow(
}
}
async function checkEmailInUse(email: string): Promise<void> {
if (!email.trim() || !email.includes("@")) {
state.emailInUse = false;
return;
}
try {
const result = await api.checkEmailInUse(email.trim());
state.emailInUse = result.inUse;
} catch {
state.emailInUse = false;
}
}
async function checkCommsChannelInUse(
channel: "discord" | "telegram" | "signal",
identifier: string,
): Promise<void> {
const trimmed = identifier.trim();
if (!trimmed) {
state[`${channel}InUse`] = false;
return;
}
try {
const result = await api.checkCommsChannelInUse(channel, trimmed);
state[`${channel}InUse`] = result.inUse;
} catch {
state[`${channel}InUse`] = false;
}
}
function proceedFromInfo() {
state.error = null;
if (state.info.didType === "web-external") {
@@ -356,6 +394,73 @@ export function createRegistrationFlow(
}
}
let checkingVerification = false;
async function checkAndAdvanceIfVerified(): Promise<boolean> {
if (checkingVerification || !state.account) return false;
checkingVerification = true;
try {
const result = await api.checkEmailVerified(state.account.did);
if (!result.verified) return false;
if (state.info.didType === "web-external") {
const password = state.mode === "passkey"
? state.account.appPassword!
: state.info.password!;
const session = await api.createSession(state.account.did, password);
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
if (state.externalDidWeb.keyMode === "byod") {
const credentials = await api.getRecommendedDidCredentials(
session.accessJwt,
);
const newPublicKeyMultibase =
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
"";
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
newPublicKeyMultibase,
state.account.handle,
getPdsEndpoint(),
);
state.externalDidWeb.updatedDidDocument = JSON.stringify(
didDoc,
null,
"\t",
);
state.step = "updated-did-doc";
persistState();
} else {
await api.activateAccount(session.accessJwt);
await finalizeSession();
state.step = "redirect-to-dashboard";
}
} else {
const password = state.mode === "passkey"
? state.account.appPassword!
: state.info.password!;
const session = await api.createSession(state.account.did, password);
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
await finalizeSession();
state.step = "redirect-to-dashboard";
}
return true;
} catch {
return false;
} finally {
checkingVerification = false;
}
}
function goBack() {
switch (state.step) {
case "key-choice":
@@ -413,9 +518,12 @@ export function createRegistrationFlow(
setPasskeyComplete,
proceedFromAppPassword,
verifyAccount,
checkAndAdvanceIfVerified,
activateAccount,
finalizeSession,
goBack,
checkEmailInUse,
checkCommsChannelInUse,
setError(msg: string) {
state.error = msg;
+2 -2
View File
@@ -1,5 +1,5 @@
export * from "./types";
export * from "./flow.svelte";
export * from "./types.ts";
export * from "./flow.svelte.ts";
export { default as VerificationStep } from "./VerificationStep.svelte";
export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte";
export { default as DidDocStep } from "./DidDocStep.svelte";
+1 -1
View File
@@ -1,4 +1,4 @@
import { api } from "./api";
import { api } from "./api.ts";
interface ServerConfigState {
serverName: string | null;
+6 -3
View File
@@ -1,8 +1,5 @@
export const routes = {
login: "/login",
register: "/register",
registerPassword: "/register-password",
registerSso: "/register-sso",
dashboard: "/dashboard",
settings: "/settings",
security: "/security",
@@ -31,6 +28,9 @@ export const routes = {
oauthDelegation: "/oauth/delegation",
oauthError: "/oauth/error",
oauthSsoRegister: "/oauth/sso-register",
oauthRegister: "/oauth/register",
oauthRegisterSso: "/oauth/register-sso",
oauthRegisterPassword: "/oauth/register-password",
} as const;
export type Route = (typeof routes)[keyof typeof routes];
@@ -55,6 +55,9 @@ export interface RouteParams {
[routes.oauthError]: { error?: string; error_description?: string };
[routes.migrate]: { code?: string; state?: string };
[routes.oauthSsoRegister]: { token?: string };
[routes.oauthRegister]: { request_uri?: string };
[routes.oauthRegisterSso]: { request_uri?: string };
[routes.oauthRegisterPassword]: { request_uri?: string };
}
export type RoutesWithParams = keyof RouteParams;
+17 -1
View File
@@ -150,17 +150,21 @@
"email": "Email",
"emailAddress": "Email Address",
"emailPlaceholder": "you@example.com",
"emailInUseWarning": "This email is already associated with another account. You can still use it, but for account recovery you may need to use your handle instead.",
"discord": "Discord",
"discordId": "Discord User ID",
"discordIdPlaceholder": "Your Discord user ID",
"discordIdHint": "Your numeric Discord user ID (enable Developer Mode to find it)",
"discordInUseWarning": "This Discord ID is already associated with another account.",
"telegram": "Telegram",
"telegramUsername": "Telegram Username",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "This Telegram username is already associated with another account.",
"signal": "Signal",
"signalNumber": "Signal Phone Number",
"signalNumberPlaceholder": "+1234567890",
"signalNumberHint": "Include country code (eg., +1 for US)",
"signalInUseWarning": "This Signal number is already associated with another account.",
"notConfigured": "not configured",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter your invite code",
@@ -275,6 +279,7 @@
"currentEmail": "Current: {email}",
"newEmail": "New Email",
"newEmailPlaceholder": "new@example.com",
"emailInUseWarning": "This email is already used by another account. You can still use it, but account recovery may require your handle.",
"changeEmailButton": "Change Email",
"requesting": "Requesting...",
"verificationCode": "Verification Code",
@@ -437,6 +442,7 @@
"noCodes": "No invite codes yet",
"available": "Available",
"used": "Used by @{handle}",
"spent": "Spent",
"disabled": "Disabled",
"usedBy": "Used by",
"disableConfirm": "Disable this invite code? It can no longer be used.",
@@ -577,7 +583,10 @@
"hideHistory": "Hide History",
"noMessages": "No messages found.",
"sent": "sent",
"failed": "failed"
"failed": "failed",
"discordInUseWarning": "This Discord ID is already associated with another account.",
"telegramInUseWarning": "This Telegram username is already associated with another account.",
"signalInUseWarning": "This Signal number is already associated with another account."
},
"repoExplorer": {
"title": "Repository Explorer",
@@ -777,6 +786,12 @@
"subtitle": "Select an account to continue",
"useAnother": "Use a different account"
},
"register": {
"title": "Create Account",
"subtitle": "Create an account to continue to",
"subtitleGeneric": "Create an account to continue",
"haveAccount": "Already have an account? Sign in"
},
"twoFactor": {
"title": "Two-Factor Authentication",
"subtitle": "Additional verification is required",
@@ -887,6 +902,7 @@
"sendCode": "Send Reset Code",
"sending": "Sending...",
"codeSent": "Password reset code sent! Check your preferred notification channel.",
"multipleAccountsWarning": "Multiple accounts share this email. The reset code was sent to the most recently created account. Use your handle instead for a specific account.",
"enterCode": "Enter the code you received and your new password.",
"code": "Reset Code",
"codePlaceholder": "Enter reset code",
+24 -2
View File
@@ -150,17 +150,21 @@
"email": "Sähköposti",
"emailAddress": "Sähköpostiosoite",
"emailPlaceholder": "sinä@esimerkki.fi",
"emailInUseWarning": "Tämä sähköposti on jo yhdistetty toiseen tiliin. Voit silti käyttää sitä, mutta tilin palauttamiseen saatat joutua käyttämään käsittelynimeäsi.",
"discord": "Discord",
"discordId": "Discord-käyttäjätunnus",
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
"telegram": "Telegram",
"telegramUsername": "Telegram-käyttäjänimi",
"telegramUsernamePlaceholder": "@käyttäjänimesi",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signal": "Signal",
"signalNumber": "Signal-puhelinnumero",
"signalNumberPlaceholder": "+358401234567",
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
"notConfigured": "ei määritetty",
"inviteCode": "Kutsukoodi",
"inviteCodePlaceholder": "Syötä kutsukoodisi",
@@ -275,6 +279,7 @@
"currentEmail": "Nykyinen: {email}",
"newEmail": "Uusi sähköposti",
"newEmailPlaceholder": "uusi@esimerkki.fi",
"emailInUseWarning": "Tämä sähköposti on jo toisen tilin käytössä. Voit silti käyttää sitä, mutta tilin palauttaminen voi vaatia käsittelynimeäsi.",
"changeEmailButton": "Vaihda sähköposti",
"requesting": "Pyydetään...",
"verificationCode": "Vahvistuskoodi",
@@ -437,6 +442,7 @@
"noCodes": "Ei vielä kutsukoodeja",
"available": "Saatavilla",
"used": "Käyttänyt @{handle}",
"spent": "Käytetty",
"disabled": "Poistettu käytöstä",
"usedBy": "Käyttänyt",
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
@@ -577,7 +583,10 @@
"hideHistory": "Piilota historia",
"noMessages": "Viestejä ei löytynyt.",
"sent": "lähetetty",
"failed": "epäonnistui"
"failed": "epäonnistui",
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin."
},
"repoExplorer": {
"title": "Tietovarastoselaaja",
@@ -696,6 +705,12 @@
"orContinueWith": "Tai jatka käyttäen",
"orUseCredentials": "Tai kirjaudu tunnuksilla"
},
"register": {
"title": "Luo tili",
"subtitle": "Luo tili jatkaaksesi sovellukseen",
"subtitleGeneric": "Luo tili jatkaaksesi",
"haveAccount": "Onko sinulla jo tili? Kirjaudu sisään"
},
"sso": {
"linkedAccounts": "Linkitetyt tilit",
"linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.",
@@ -829,7 +844,13 @@
"error_expired": "Rekisteröintisessio on vanhentunut. Yritä uudelleen.",
"error_handle_required": "Valitse käsittelynimi",
"emailVerifiedByProvider": "Tämä sähköposti on vahvistettu {provider} kautta. Lisävahvistusta ei tarvita.",
"emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se."
"emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se.",
"infoAfterTitle": "Tilin luomisen jälkeen",
"infoAddPassword": "Lisää salasana perinteistä kirjautumista varten",
"infoAddPasskey": "Määritä pääsyavain salasanattomaan kirjautumiseen",
"infoLinkProviders": "Linkitä lisää SSO-palveluntarjoajia",
"infoChangeHandle": "Vaihda käsittelynimesi tai käytä omaa verkkotunnusta",
"tryAgain": "Yritä uudelleen"
},
"verify": {
"title": "Vahvista tilisi",
@@ -881,6 +902,7 @@
"sendCode": "Lähetä palautuskoodi",
"sending": "Lähetetään...",
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
"multipleAccountsWarning": "Useampi tili käyttää tätä sähköpostia. Palautuskoodi lähetettiin viimeksi luodulle tilille. Käytä käsittelynimeäsi tietylle tilille.",
"enterCode": "Syötä saamasi koodi ja uusi salasanasi.",
"code": "Palautuskoodi",
"codePlaceholder": "Syötä palautuskoodi",
+24 -2
View File
@@ -143,17 +143,21 @@
"email": "メール",
"emailAddress": "メールアドレス",
"emailPlaceholder": "you@example.com",
"emailInUseWarning": "このメールアドレスは既に別のアカウントに関連付けられています。引き続き使用できますが、アカウント回復にはハンドルが必要になる場合があります。",
"discord": "Discord",
"discordId": "Discord ユーザー ID",
"discordIdPlaceholder": "Discord ユーザー ID",
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
"telegram": "Telegram",
"telegramUsername": "Telegram ユーザー名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signal": "Signal",
"signalNumber": "Signal 電話番号",
"signalNumberPlaceholder": "+81XXXXXXXXXX",
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
"notConfigured": "未設定",
"inviteCode": "招待コード",
"inviteCodePlaceholder": "招待コードを入力",
@@ -268,6 +272,7 @@
"currentEmail": "現在: {email}",
"newEmail": "新しいメール",
"newEmailPlaceholder": "new@example.com",
"emailInUseWarning": "このメールアドレスは既に別のアカウントで使用されています。引き続き使用できますが、アカウント回復にはハンドルが必要になる場合があります。",
"changeEmailButton": "メールを変更",
"requesting": "リクエスト中...",
"verificationCode": "確認コード",
@@ -430,6 +435,7 @@
"noCodes": "招待コードはまだありません",
"available": "利用可能",
"used": "@{handle} が使用済み",
"spent": "使用済み",
"disabled": "無効",
"usedBy": "使用者",
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
@@ -570,7 +576,10 @@
"hideHistory": "履歴を隠す",
"noMessages": "メッセージが見つかりません。",
"sent": "送信済み",
"failed": "失敗"
"failed": "失敗",
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。"
},
"repoExplorer": {
"title": "リポジトリエクスプローラー",
@@ -689,6 +698,12 @@
"orContinueWith": "または次の方法で続行",
"orUseCredentials": "または認証情報でサインイン"
},
"register": {
"title": "アカウント作成",
"subtitle": "続行するにはアカウントを作成してください",
"subtitleGeneric": "続行するにはアカウントを作成してください",
"haveAccount": "すでにアカウントをお持ちですか?サインイン"
},
"sso": {
"linkedAccounts": "連携アカウント",
"linkedAccountsDesc": "シングルサインオン用に連携された外部アカウント。",
@@ -822,7 +837,13 @@
"error_expired": "登録セッションが期限切れです。もう一度お試しください。",
"error_handle_required": "ハンドルを選択してください",
"emailVerifiedByProvider": "このメールアドレスは{provider}で確認済みです。追加の確認は不要です。",
"emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。"
"emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。",
"infoAfterTitle": "アカウント作成後",
"infoAddPassword": "従来のログイン用にパスワードを追加",
"infoAddPasskey": "パスワードレスサインイン用にパスキーを設定",
"infoLinkProviders": "追加のSSOプロバイダーを連携",
"infoChangeHandle": "ハンドルの変更またはカスタムドメインの使用",
"tryAgain": "もう一度試す"
},
"verify": {
"title": "アカウント確認",
@@ -874,6 +895,7 @@
"sendCode": "リセットコードを送信",
"sending": "送信中...",
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
"multipleAccountsWarning": "複数のアカウントがこのメールを共有しています。リセットコードは最後に作成されたアカウントに送信されました。特定のアカウントにはハンドルを使用してください。",
"enterCode": "受け取ったコードと新しいパスワードを入力してください。",
"code": "リセットコード",
"codePlaceholder": "リセットコードを入力",
+25 -3
View File
@@ -143,17 +143,21 @@
"email": "이메일",
"emailAddress": "이메일 주소",
"emailPlaceholder": "you@example.com",
"emailInUseWarning": "이 이메일은 이미 다른 계정과 연결되어 있습니다. 계속 사용할 수 있지만, 계정 복구 시 핸들이 필요할 수 있습니다.",
"discord": "Discord",
"discordId": "Discord 사용자 ID",
"discordIdPlaceholder": "Discord 사용자 ID",
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
"telegram": "Telegram",
"telegramUsername": "Telegram 사용자 이름",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signal": "Signal",
"signalNumber": "Signal 전화번호",
"signalNumberPlaceholder": "+821012345678",
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
"notConfigured": "구성되지 않음",
"inviteCode": "초대 코드",
"inviteCodePlaceholder": "초대 코드 입력",
@@ -269,6 +273,7 @@
"newEmail": "새 이메일",
"newEmailPlaceholder": "new@example.com",
"changeEmailButton": "이메일 변경",
"emailInUseWarning": "이 이메일은 이미 다른 계정과 연결되어 있습니다. 계속 사용하실 수 있지만, 계정 복구 시 이메일 대신 핸들을 사용해야 할 수 있습니다.",
"requesting": "요청 중...",
"verificationCode": "인증 코드",
"verificationCodePlaceholder": "인증 코드 입력",
@@ -430,6 +435,7 @@
"noCodes": "초대 코드가 아직 없습니다",
"available": "사용 가능",
"used": "@{handle}이(가) 사용함",
"spent": "소진됨",
"disabled": "비활성화됨",
"usedBy": "사용자",
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
@@ -570,7 +576,10 @@
"hideHistory": "기록 숨기기",
"noMessages": "메시지가 없습니다.",
"sent": "전송됨",
"failed": "실패"
"failed": "실패",
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다."
},
"repoExplorer": {
"title": "저장소 탐색기",
@@ -689,6 +698,12 @@
"orContinueWith": "또는 다음으로 계속",
"orUseCredentials": "또는 자격 증명으로 로그인"
},
"register": {
"title": "계정 만들기",
"subtitle": "계속하려면 계정을 만드세요",
"subtitleGeneric": "계속하려면 계정을 만드세요",
"haveAccount": "이미 계정이 있으신가요? 로그인"
},
"sso": {
"linkedAccounts": "연결된 계정",
"linkedAccountsDesc": "싱글 사인온을 위해 연결된 외부 계정입니다.",
@@ -822,7 +837,13 @@
"error_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.",
"error_handle_required": "핸들을 선택해 주세요",
"emailVerifiedByProvider": "이 이메일은 {provider}에서 인증되었습니다. 추가 인증이 필요하지 않습니다.",
"emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다."
"emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다.",
"infoAfterTitle": "계정 생성 후",
"infoAddPassword": "기존 로그인을 위한 비밀번호 추가",
"infoAddPasskey": "비밀번호 없는 로그인을 위한 패스키 설정",
"infoLinkProviders": "추가 SSO 제공자 연결",
"infoChangeHandle": "핸들 변경 또는 사용자 정의 도메인 사용",
"tryAgain": "다시 시도"
},
"verify": {
"title": "계정 인증",
@@ -886,7 +907,8 @@
"success": "비밀번호가 재설정되었습니다!",
"requestNewCode": "새 코드 요청",
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
"multipleAccountsWarning": "여러 계정에서 이 이메일을 공유하고 있습니다. 재설정 코드는 가장 최근에 생성된 계정으로 전송되었습니다. 특정 계정을 복구하려면 핸들을 사용하세요."
},
"recoverPasskey": {
"title": "계정 복구",
+26 -4
View File
@@ -147,13 +147,16 @@
"discordId": "Discord användar-ID",
"discordIdPlaceholder": "Ditt Discord användar-ID",
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
"telegram": "Telegram",
"telegramUsername": "Telegram-användarnamn",
"telegramUsernamePlaceholder": "@dittanvändarnamn",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signal": "Signal",
"signalNumber": "Signal-telefonnummer",
"signalNumberPlaceholder": "+46701234567",
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
"notConfigured": "ej konfigurerad",
"inviteCode": "Inbjudningskod",
"inviteCodePlaceholder": "Ange din inbjudningskod",
@@ -161,6 +164,7 @@
"createButton": "Skapa konto",
"alreadyHaveAccount": "Har du redan ett konto?",
"signIn": "Logga in",
"emailInUseWarning": "Denna e-post är redan kopplad till ett annat konto. Du kan fortfarande använda den, men för kontoåterställning kan du behöva använda ditt användarnamn istället.",
"passkeyAccount": "Nyckel",
"passwordAccount": "Lösenord",
"ssoAccount": "SSO",
@@ -269,6 +273,7 @@
"newEmail": "Ny e-post",
"newEmailPlaceholder": "ny@exempel.se",
"changeEmailButton": "Ändra e-post",
"emailInUseWarning": "Denna e-post används redan av ett annat konto. Du kan fortfarande använda den, men kontoåterställning kan kräva ditt användarnamn.",
"requesting": "Begär...",
"verificationCode": "Verifieringskod",
"verificationCodePlaceholder": "Ange verifieringskod",
@@ -435,7 +440,8 @@
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
"created": "Inbjudningskod skapad",
"copy": "Kopiera",
"createdOn": "Skapad {date}"
"createdOn": "Skapad {date}",
"spent": "Förbrukad"
},
"security": {
"title": "Säkerhet",
@@ -570,7 +576,10 @@
"hideHistory": "Dölj historik",
"noMessages": "Inga meddelanden hittades.",
"sent": "skickad",
"failed": "misslyckades"
"failed": "misslyckades",
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto."
},
"repoExplorer": {
"title": "Dataförvarsutforskare",
@@ -689,6 +698,12 @@
"orContinueWith": "Eller fortsätt med",
"orUseCredentials": "Eller logga in med uppgifter"
},
"register": {
"title": "Skapa konto",
"subtitle": "Skapa ett konto med {app}",
"subtitleGeneric": "Skapa ett konto för att fortsätta",
"haveAccount": "Har du redan ett konto?"
},
"sso": {
"linkedAccounts": "Länkade konton",
"linkedAccountsDesc": "Externa konton länkade till din identitet för enkel inloggning.",
@@ -822,7 +837,13 @@
"error_expired": "Registreringssessionen har löpt ut. Försök igen.",
"error_handle_required": "Välj ett användarnamn",
"emailVerifiedByProvider": "Denna e-post är verifierad av {provider}. Ingen ytterligare verifiering behövs.",
"emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den."
"emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den.",
"infoAfterTitle": "Efter att du skapat ditt konto",
"infoAddPassword": "Lägg till ett lösenord för traditionell inloggning",
"infoAddPasskey": "Konfigurera en nyckel för lösenordsfri inloggning",
"infoLinkProviders": "Länka ytterligare SSO-leverantörer",
"infoChangeHandle": "Byt användarnamn eller använd en egen domän",
"tryAgain": "Försök igen"
},
"verify": {
"title": "Verifiera ditt konto",
@@ -886,7 +907,8 @@
"success": "Lösenord återställt!",
"requestNewCode": "Begär ny kod",
"passwordsMismatch": "Lösenorden matchar inte",
"passwordLength": "Lösenordet måste vara minst 8 tecken"
"passwordLength": "Lösenordet måste vara minst 8 tecken",
"multipleAccountsWarning": "Flera konton delar denna e-post. Återställningskoden skickades till det senast skapade kontot. Använd ditt användarnamn istället för ett specifikt konto."
},
"recoverPasskey": {
"title": "Återställ ditt konto",
+26 -4
View File
@@ -147,13 +147,16 @@
"discordId": "Discord 用户 ID",
"discordIdPlaceholder": "您的 Discord 用户 ID",
"discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)",
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
"telegram": "Telegram",
"telegramUsername": "Telegram 用户名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signal": "Signal",
"signalNumber": "Signal 电话号码",
"signalNumberPlaceholder": "+1234567890",
"signalNumberHint": "包含国家代码(例如中国为 +86)",
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
"notConfigured": "未配置",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入您的邀请码",
@@ -161,6 +164,7 @@
"createButton": "创建账户",
"alreadyHaveAccount": "已有账户?",
"signIn": "立即登录",
"emailInUseWarning": "此邮箱已与其他账户关联。您仍可使用,但账户恢复时可能需要使用用户名。",
"passkeyAccount": "通行密钥",
"passwordAccount": "密码",
"ssoAccount": "SSO",
@@ -269,6 +273,7 @@
"newEmail": "新邮箱",
"newEmailPlaceholder": "new@example.com",
"changeEmailButton": "更改邮箱",
"emailInUseWarning": "此邮箱已被其他账户使用。您仍可使用,但账户恢复可能需要使用用户名。",
"requesting": "请求中...",
"verificationCode": "验证码",
"verificationCodePlaceholder": "输入验证码",
@@ -435,7 +440,8 @@
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
"created": "邀请码已创建",
"copy": "复制",
"createdOn": "创建于 {date}"
"createdOn": "创建于 {date}",
"spent": "已使用"
},
"security": {
"title": "安全设置",
@@ -570,7 +576,10 @@
"hideHistory": "隐藏历史",
"noMessages": "暂无消息记录",
"sent": "已发送",
"failed": "发送失败"
"failed": "发送失败",
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。"
},
"repoExplorer": {
"title": "数据浏览器",
@@ -689,6 +698,12 @@
"orContinueWith": "或使用以下方式继续",
"orUseCredentials": "或使用凭证登录"
},
"register": {
"title": "创建账户",
"subtitle": "使用 {app} 创建账户",
"subtitleGeneric": "创建账户以继续",
"haveAccount": "已有账户?"
},
"sso": {
"linkedAccounts": "已关联账户",
"linkedAccountsDesc": "已关联到您身份的外部账户,用于单点登录。",
@@ -822,7 +837,13 @@
"error_expired": "注册会话已过期。请重试。",
"error_handle_required": "请选择一个昵称",
"emailVerifiedByProvider": "此邮箱已由{provider}验证。无需额外验证。",
"emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。"
"emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。",
"infoAfterTitle": "创建账户后",
"infoAddPassword": "添加密码以使用传统方式登录",
"infoAddPasskey": "设置通行密钥以实现无密码登录",
"infoLinkProviders": "关联其他SSO提供商",
"infoChangeHandle": "更改用户名或使用自定义域名",
"tryAgain": "重试"
},
"verify": {
"title": "验证账户",
@@ -886,7 +907,8 @@
"success": "密码重置成功!",
"requestNewCode": "重新获取验证码",
"passwordsMismatch": "两次输入的密码不一致",
"passwordLength": "密码至少需要8位字符"
"passwordLength": "密码至少需要8位字符",
"multipleAccountsWarning": "多个账户共享此邮箱。重置验证码已发送至最新创建的账户。如需恢复特定账户,请使用用户名。"
},
"recoverPasskey": {
"title": "恢复账户",
+6 -9
View File
@@ -1010,7 +1010,7 @@
}
.code {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
@@ -1032,7 +1032,7 @@
}
.action-btn.danger:hover {
background: #900;
filter: brightness(0.8);
}
.muted {
@@ -1049,15 +1049,12 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal);
}
.modal {
@@ -1117,7 +1114,7 @@
}
.mono {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
}
+1 -5
View File
@@ -281,7 +281,7 @@
.password-code {
display: block;
font-size: var(--text-xl);
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
@@ -469,8 +469,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+44 -9
View File
@@ -33,6 +33,9 @@
let verifyingChannel = $state<string | null>(null)
let verificationCode = $state('')
let historyLoading = $state(true)
let discordInUse = $state(false)
let telegramInUse = $state(false)
let signalInUse = $state(false)
let messages = $state<Array<{
createdAt: string
channel: string
@@ -132,6 +135,31 @@
function formatDate(dateStr: string): string {
return formatDateTime(dateStr)
}
async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) {
const trimmed = identifier.trim()
if (!trimmed) {
switch (channel) {
case 'discord': discordInUse = false; break
case 'telegram': telegramInUse = false; break
case 'signal': signalInUse = false; break
}
return
}
try {
const result = await api.checkCommsChannelInUse(channel, trimmed)
switch (channel) {
case 'discord': discordInUse = result.inUse; break
case 'telegram': telegramInUse = result.inUse; break
case 'signal': signalInUse = result.inUse; break
}
} catch {
switch (channel) {
case 'discord': discordInUse = false; break
case 'telegram': telegramInUse = false; break
case 'signal': signalInUse = false; break
}
}
}
const channels = ['email', 'discord', 'telegram', 'signal']
function getChannelName(id: string): string {
switch (id) {
@@ -242,6 +270,7 @@
id="discord"
type="text"
bind:value={discordId}
onblur={() => checkChannelInUse('discord', discordId)}
placeholder={$_('register.discordIdPlaceholder')}
disabled={saving || !isChannelAvailableOnServer('discord')}
/>
@@ -250,6 +279,9 @@
{/if}
</div>
<p class="config-hint">{$_('comms.discordIdHint')}</p>
{#if discordInUse}
<p class="config-hint warning">{$_('comms.discordInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'discord'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
@@ -277,6 +309,7 @@
id="telegram"
type="text"
bind:value={telegramUsername}
onblur={() => checkChannelInUse('telegram', telegramUsername)}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={saving || !isChannelAvailableOnServer('telegram')}
/>
@@ -285,6 +318,9 @@
{/if}
</div>
<p class="config-hint">{$_('comms.telegramHint')}</p>
{#if telegramInUse}
<p class="config-hint warning">{$_('comms.telegramInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'telegram'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
@@ -312,6 +348,7 @@
id="signal"
type="tel"
bind:value={signalNumber}
onblur={() => checkChannelInUse('signal', signalNumber)}
placeholder={$_('register.signalNumberPlaceholder')}
disabled={saving || !isChannelAvailableOnServer('signal')}
/>
@@ -320,6 +357,9 @@
{/if}
</div>
<p class="config-hint">{$_('comms.signalHint')}</p>
{#if signalInUse}
<p class="config-hint warning">{$_('comms.signalInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'signal'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
@@ -573,6 +613,10 @@
margin: 0;
}
.config-hint.warning {
color: var(--warning-text);
}
.actions {
display: flex;
justify-content: flex-end;
@@ -677,11 +721,6 @@
margin-bottom: var(--space-1);
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.no-messages {
color: var(--text-secondary);
font-style: italic;
@@ -772,8 +811,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
-4
View File
@@ -770,8 +770,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+1 -6
View File
@@ -425,7 +425,7 @@
}
.mono {
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
word-break: break-all;
}
@@ -523,11 +523,6 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.deactivated-banner {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
@@ -333,8 +333,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+1 -5
View File
@@ -361,7 +361,7 @@
}
.handle-item span {
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
@@ -475,8 +475,4 @@
height: 250px;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+9 -6
View File
@@ -152,6 +152,8 @@
<span class="status disabled">{$_('inviteCodes.disabled')}</span>
{:else if code.uses.length > 0}
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span>
{:else if code.available === 0}
<span class="status spent">{$_('inviteCodes.spent')}</span>
{:else}
<span class="status available">{$_('inviteCodes.available')}</span>
{/if}
@@ -217,7 +219,7 @@
.code-display code {
font-size: var(--text-lg);
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
flex: 1;
}
@@ -273,7 +275,7 @@
}
.code-main code {
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
@@ -317,6 +319,11 @@
color: var(--text-secondary);
}
.status.spent {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
.status.disabled {
background: var(--error-bg);
color: var(--error-text);
@@ -334,8 +341,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+1 -1
View File
@@ -349,7 +349,7 @@
.account-did {
font-size: var(--text-xs);
color: var(--text-muted);
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
+7 -35
View File
@@ -208,8 +208,8 @@
{/if}
{#if oauthLoading}
<div class="oauth-loading">
<div class="loading-spinner"></div>
<div class="loading">
<div class="spinner md"></div>
<p>{$_('migration.oauthCompleting')}</p>
</div>
{:else if oauthError}
@@ -317,7 +317,7 @@
flex-direction: column;
align-items: stretch;
background: var(--bg-secondary);
border: 1px solid var(--border);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: left;
@@ -411,18 +411,18 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal);
}
.modal {
background: var(--bg-primary);
border-radius: var(--radius-xl);
padding: var(--space-6);
max-width: 400px;
max-width: var(--width-sm);
width: 90%;
}
@@ -450,7 +450,7 @@
}
.detail-row:not(:last-child) {
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-color);
}
.detail-row .label {
@@ -472,34 +472,6 @@
justify-content: flex-end;
}
.oauth-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12);
text-align: center;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-4);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.oauth-loading p {
color: var(--text-secondary);
margin: 0;
}
.oauth-error {
max-width: 500px;
margin: 0 auto;
+1 -1
View File
@@ -487,7 +487,7 @@
}
.account-info .did {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
word-break: break-all;
+24 -24
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { _ } from '../lib/i18n'
import { routes, buildUrl } from '../lib/types/routes'
import { getRequestUriFromUrl } from '../lib/oauth'
function getError(): string {
const params = new URLSearchParams(window.location.search)
@@ -15,11 +17,19 @@
window.history.back()
}
function handleSignIn() {
const requestUri = getRequestUriFromUrl()
const url = requestUri
? buildUrl(routes.oauthLogin, { request_uri: requestUri })
: routes.login
window.location.href = `/app${url}`
}
let error = $derived(getError())
let errorDescription = $derived(getErrorDescription())
</script>
<div class="oauth-error-container">
<div class="page-sm text-center">
<h1>{$_('oauth.error.title')}</h1>
<div class="error-box">
@@ -29,19 +39,17 @@
{/if}
</div>
<button type="button" onclick={handleBack}>
{$_('oauth.error.tryAgain')}
</button>
<div class="actions">
<button type="button" onclick={handleBack}>
{$_('oauth.error.tryAgain')}
</button>
<button type="button" class="secondary" onclick={handleSignIn}>
{$_('common.signIn')}
</button>
</div>
</div>
<style>
.oauth-error-container {
max-width: var(--width-sm);
margin: var(--space-9) auto;
padding: var(--space-7);
text-align: center;
}
h1 {
margin: 0 0 var(--space-6) 0;
color: var(--error-text);
@@ -56,7 +64,7 @@
}
.error-code {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-base);
color: var(--error-text);
margin-bottom: var(--space-2);
@@ -67,17 +75,9 @@
font-size: var(--text-sm);
}
button {
padding: var(--space-3) var(--space-6);
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
cursor: pointer;
}
button:hover {
background: var(--accent-hover);
.actions {
display: flex;
gap: var(--space-3);
justify-content: center;
}
</style>
+18 -122
View File
@@ -63,7 +63,7 @@
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoProviders = data.providers || []
ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
}
} catch {
ssoProviders = []
@@ -337,35 +337,12 @@
}
}
async function handleCancel() {
const requestUri = getRequestUri()
if (!requestUri) {
window.history.back()
return
}
submitting = true
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ request_uri: requestUri })
})
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
}
} catch {
window.history.back()
}
function handleCancel() {
window.location.href = '/'
}
</script>
<div class="oauth-login-container">
<div class="page-sm">
<header class="page-header">
<h1>{$_('oauth.login.title')}</h1>
<p class="subtitle">
@@ -378,7 +355,7 @@
</header>
{#if error}
<div class="error">{error}</div>
<div class="message error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
@@ -406,7 +383,7 @@
disabled={submitting || ssoLoading !== null}
>
{#if ssoLoading === provider.provider}
<span class="loading-spinner"></span>
<span class="spinner sm"></span>
{:else}
<SsoIcon provider={provider.icon} size={20} />
{/if}
@@ -543,25 +520,6 @@
text-decoration: underline;
}
.oauth-login-container {
max-width: var(--width-md);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
h1 {
margin: 0 0 var(--space-2) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
form {
display: flex;
flex-direction: column;
@@ -582,6 +540,18 @@
}
}
.auth-methods.single-method {
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
}
.passkey-method,
.password-method {
display: flex;
@@ -652,32 +622,6 @@
}
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
input[type="text"],
input[type="password"] {
padding: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--text-base);
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.remember-device {
display: flex;
align-items: center;
@@ -692,15 +636,6 @@
height: 16px;
}
.error {
padding: var(--space-3);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-md);
color: var(--error-text);
margin-bottom: var(--space-4);
}
.actions {
display: flex;
gap: var(--space-4);
@@ -709,17 +644,6 @@
.actions button {
flex: 1;
padding: var(--space-3);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-row {
@@ -757,7 +681,6 @@
background: var(--accent-hover);
}
.passkey-btn {
display: flex;
align-items: center;
@@ -867,31 +790,4 @@
opacity: 0.6;
cursor: not-allowed;
}
.auth-methods.single-method {
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
-6
View File
@@ -197,12 +197,6 @@
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-indicator p {
margin: 0;
color: var(--text-secondary);
File diff suppressed because it is too large Load Diff
-668
View File
@@ -1,668 +0,0 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
createRegistrationFlow,
restoreRegistrationFlow,
VerificationStep,
KeyChoiceStep,
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
let ssoAvailable = $state(false)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
checkSsoAvailable()
}
})
async function checkSsoAvailable() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoAvailable = (data.providers?.length ?? 0) > 0
}
} catch {
ssoAvailable = false
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate('/dashboard')
}
})
let creatingStarted = false
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasskeyAccount()
}
})
async function loadServerInfo() {
try {
const restored = restoreRegistrationFlow()
if (restored && restored.state.mode === 'passkey') {
flow = restored
serverInfo = await api.describeServer()
} else {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
}
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
loadingServerInfo = false
}
}
function validateInfoStep(): string | null {
if (!flow) return 'Flow not initialized'
const info = flow.info
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return $_('registerPasskey.errors.inviteRequired')
}
if (info.didType === 'web-external') {
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
}
switch (info.verificationChannel) {
case 'email':
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
break
}
return null
}
async function handleInfoSubmit(e: Event) {
e.preventDefault()
if (!flow) return
const validationError = validateInfoStep()
if (validationError) {
flow.setError(validationError)
return
}
if (!window.PublicKeyCredential) {
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
return
}
flow.clearError()
flow.proceedFromInfo()
}
async function handleCreateAccount() {
if (!flow) return
await flow.createPasskeyAccount()
}
async function handlePasskeyRegistration() {
if (!flow || !flow.account) return
flow.setSubmitting(true)
flow.clearError()
try {
const { options } = await api.startPasskeyRegistrationForSetup(
flow.account.did,
flow.account.setupToken!,
passkeyName || undefined
)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
if (!credential) {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
flow.setSubmitting(false)
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
const result = await api.completePasskeySetup(
flow.account.did,
flow.account.setupToken!,
credentialResponse,
passkeyName || undefined
)
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
} else if (err instanceof ApiError) {
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
} else if (err instanceof Error) {
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
} else {
flow.setError($_('registerPasskey.errors.passkeyFailed'))
}
} finally {
flow.setSubmitting(false)
}
}
async function handleComplete() {
if (flow) {
await flow.finalizeSession()
}
navigate('/dashboard')
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return $_('register.email')
case 'discord': return $_('register.discord')
case 'telegram': return $_('register.telegram')
case 'signal': return $_('register.signal')
default: return ch
}
}
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
const domain = serverInfo?.availableUserDomains?.[0]
if (domain) return `${flow.info.handle.trim()}.${domain}`
return flow.info.handle.trim()
})
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
function getSubtitle(): string {
if (!flow) return ''
switch (flow.state.step) {
case 'info': return $_('registerPasskey.subtitle')
case 'key-choice': return $_('registerPasskey.subtitleKeyChoice')
case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc')
case 'creating': return $_('registerPasskey.subtitleCreating')
case 'passkey': return $_('registerPasskey.subtitlePasskey')
case 'app-password': return $_('registerPasskey.subtitleAppPassword')
case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc')
case 'activating': return $_('registerPasskey.subtitleActivating')
case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete')
default: return ''
}
}
</script>
<div class="register-page">
<header class="page-header">
<h1>{$_('registerPasskey.title')}</h1>
<p class="subtitle">{getSubtitle()}</p>
</header>
{#if flow?.state.error}
<div class="message error">{flow.state.error}</div>
{/if}
{#if loadingServerInfo || !flow}
<div class="loading"></div>
{:else if flow.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href={getFullUrl(routes.migrate)} class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
<AccountTypeSwitcher active="passkey" {ssoAvailable} />
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('registerPasskey.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
placeholder={$_('registerPasskey.handlePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.contactMethod')}</legend>
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
<div class="field">
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('registerPasskey.email')}</label>
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.signalNumberHint')}</p>
</div>
{/if}
</fieldset>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
</div>
{/if}
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
</button>
</form>
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
</p>
</div>
</div>
<aside class="info-panel">
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
</aside>
</div>
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep
{flow}
type="initial"
onConfirm={handleCreateAccount}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
<p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
{:else if flow.state.step === 'passkey'}
<div class="step-content">
<div class="field">
<label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label>
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} />
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<div class="info-box">
<p>{$_('registerPasskey.passkeyPrompt')}</p>
<ul>
<li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
<li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
<li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
</ul>
</div>
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
{flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
</button>
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
{$_('registerPasskey.back')}
</button>
</div>
{:else if flow.state.step === 'app-password'}
<AppPasswordStep {flow} />
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep
{flow}
type="updated"
onConfirm={() => flow?.activateAccount()}
/>
{:else if flow.state.step === 'redirect-to-dashboard'}
<p class="loading">{$_('registerPasskey.redirecting')}</p>
{/if}
</div>
<style>
.register-page {
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
.form-section {
min-width: 0;
}
.form-links {
margin-top: var(--space-6);
}
.link-text {
text-align: center;
color: var(--text-secondary);
}
.link-text a {
color: var(--accent);
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
h1 {
margin: 0 0 var(--space-3) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 var(--space-7) 0;
}
.loading {
text-align: center;
color: var(--text-secondary);
}
form, .step-content {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.required {
color: var(--error-text);
}
.section-hint {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0 0 var(--space-5) 0;
}
.radio-group {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.radio-label {
display: flex;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-base);
font-weight: var(--font-normal);
margin-bottom: 0;
}
.radio-label input[type="radio"] {
margin-top: var(--space-1);
width: auto;
}
.radio-content {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.radio-hint {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.radio-label.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-hint.disabled-hint {
color: var(--warning-text);
}
.warning-box {
margin-top: var(--space-5);
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-3);
color: var(--warning-text);
}
.warning-box ul {
margin: var(--space-4) 0 0 0;
padding-left: var(--space-5);
}
.warning-box li {
margin-bottom: var(--space-3);
line-height: var(--leading-normal);
}
.warning-box li:last-child {
margin-bottom: 0;
}
.info-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-5);
font-size: var(--text-sm);
}
.info-box p {
margin: 0 0 var(--space-3) 0;
color: var(--text-secondary);
}
.info-box ul {
margin: 0;
padding-left: var(--space-5);
color: var(--text-secondary);
}
.info-box li {
margin-bottom: var(--space-2);
}
.passkey-btn {
padding: var(--space-5);
font-size: var(--text-lg);
}
</style>
+560
View File
@@ -0,0 +1,560 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
createRegistrationFlow,
restoreRegistrationFlow,
VerificationStep,
KeyChoiceStep,
DidDocStep,
} from '../lib/registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
let ssoAvailable = $state(false)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let confirmPassword = $state('')
let clientName = $state<string | null>(null)
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
ensureRequestUri().then((requestUri) => {
if (!requestUri) return
loadServerInfo()
checkSsoAvailable()
fetchClientName()
}).catch((err) => {
console.error('Failed to ensure OAuth request URI:', err)
})
}
})
async function fetchClientName() {
const requestUri = getRequestUriFromUrl()
if (!requestUri) return
try {
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
clientName = data.client_name || null
}
} catch {
clientName = null
}
}
async function checkSsoAvailable() {
try {
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoAvailable = (data.providers?.length ?? 0) > 0
}
} catch {
ssoAvailable = false
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
completeOAuthRegistration()
}
})
let creatingStarted = false
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasswordAccount()
}
})
async function loadServerInfo() {
try {
const restored = restoreRegistrationFlow()
if (restored && restored.state.mode === 'password') {
flow = restored
serverInfo = await api.describeServer()
} else {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('password', hostname)
}
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
loadingServerInfo = false
}
}
function validateInfoStep(): string | null {
if (!flow) return 'Flow not initialized'
const info = flow.info
if (!info.handle.trim()) return $_('register.validation.handleRequired')
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
if (!info.password) return $_('register.validation.passwordRequired')
if (info.password.length < 8) return $_('register.validation.passwordLength')
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return $_('register.validation.inviteCodeRequired')
}
if (info.didType === 'web-external') {
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
}
switch (info.verificationChannel) {
case 'email':
if (!info.email.trim()) return $_('register.validation.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
break
}
return null
}
async function handleInfoSubmit(e: Event) {
e.preventDefault()
if (!flow) return
const validationError = validateInfoStep()
if (validationError) {
flow.setError(validationError)
return
}
flow.clearError()
flow.proceedFromInfo()
}
async function handleCreateAccount() {
if (!flow) return
await flow.createPasswordAccount()
}
async function handleComplete() {
if (flow) {
await flow.finalizeSession()
}
navigate(routes.dashboard)
}
async function completeOAuthRegistration() {
const requestUri = getRequestUriFromUrl()
if (!requestUri || !flow?.account) {
navigate(routes.dashboard)
return
}
try {
const response = await fetch('/oauth/register/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
request_uri: requestUri,
did: flow.account.did,
app_password: flow.account.appPassword,
}),
})
const data = await response.json()
if (!response.ok) {
flow.setError(data.error_description || data.error || $_('common.error'))
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
navigate(routes.dashboard)
} catch (err) {
console.error('OAuth registration completion failed:', err)
flow.setError(err instanceof Error ? err.message : $_('common.error'))
}
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return $_('register.email')
case 'discord': return $_('register.discord')
case 'telegram': return $_('register.telegram')
case 'signal': return $_('register.signal')
default: return ch
}
}
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
const domain = serverInfo?.availableUserDomains?.[0]
if (domain) return `${flow.info.handle.trim()}.${domain}`
return flow.info.handle.trim()
})
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
function getSubtitle(): string {
if (!flow) return ''
switch (flow.state.step) {
case 'info': return $_('register.subtitle')
case 'key-choice': return $_('register.subtitleKeyChoice')
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
case 'creating': return $_('common.creating')
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
case 'activating': return $_('register.subtitleActivating')
case 'redirect-to-dashboard': return $_('register.subtitleComplete')
default: return ''
}
}
</script>
<div class="page">
<header class="page-header">
<h1>{$_('register.title')}</h1>
<p class="subtitle">{getSubtitle()}</p>
{#if clientName}
<p class="client-name">{$_('oauth.login.subtitle')} <strong>{clientName}</strong></p>
{/if}
</header>
{#if flow?.state.error}
<div class="message error">{flow.state.error}</div>
{/if}
{#if loadingServerInfo || !flow}
<div class="loading">
<div class="spinner md"></div>
</div>
{:else if flow.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href={getFullUrl(routes.migrate)} class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
<AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('register.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<div class="form-row">
<div class="field">
<label for="password">{$_('register.password')}</label>
<input
id="password"
type="password"
bind:value={flow.info.password}
placeholder={$_('register.passwordPlaceholder')}
disabled={flow.state.submitting}
required
minlength="8"
/>
</div>
<div class="field">
<label for="confirm-password">{$_('register.confirmPassword')}</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder={$_('register.confirmPasswordPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
</div>
<fieldset class="section-fieldset">
<legend>{$_('register.identityType')}</legend>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
<span class="radio-hint">{$_('register.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('register.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('register.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('register.didWebBYOD')}</strong>
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('register.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('register.externalDid')}</label>
<input
id="external-did"
type="text"
bind:value={flow.info.externalDid}
placeholder={$_('register.externalDidPlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.externalDidHint')}</p>
</div>
{/if}
</fieldset>
<fieldset class="section-fieldset">
<legend>{$_('register.contactMethod')}</legend>
<div class="contact-fields">
<div class="field">
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
onblur={() => flow?.checkEmailInUse(flow.info.email)}
placeholder={$_('register.emailPlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.state.emailInUse}
<p class="hint warning">{$_('register.emailInUseWarning')}</p>
{/if}
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input
id="discord-id"
type="text"
bind:value={flow.info.discordId}
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
placeholder={$_('register.discordIdPlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
{#if flow.state.discordInUse}
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
{/if}
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={flow.info.telegramUsername}
onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.state.telegramInUse}
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
{/if}
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalNumber}
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
placeholder={$_('register.signalNumberPlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
{#if flow.state.signalInUse}
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
{/if}
</div>
{/if}
</div>
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
<input
id="invite-code"
type="text"
bind:value={flow.info.inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{/if}
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
</button>
</form>
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
</p>
</div>
</div>
<aside class="info-panel">
<h3>{$_('register.identityHint')}</h3>
<p>{$_('register.infoIdentityDesc')}</p>
<h3>{$_('register.contactMethodHint')}</h3>
<p>{$_('register.infoContactDesc')}</p>
<h3>{$_('register.infoNextTitle')}</h3>
<p>{$_('register.infoNextDesc')}</p>
</aside>
</div>
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep
{flow}
type="initial"
onConfirm={handleCreateAccount}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
<div class="loading">
<div class="spinner md"></div>
<p>{$_('common.creating')}</p>
</div>
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep
{flow}
type="updated"
onConfirm={() => flow?.activateAccount()}
/>
{:else if flow.state.step === 'redirect-to-dashboard'}
<div class="loading">
<div class="spinner md"></div>
<p>{$_('register.redirecting')}</p>
</div>
{/if}
</div>
<style>
.client-name {
color: var(--text-secondary);
margin-top: var(--space-2);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
button[type="submit"] {
margin-top: var(--space-3);
}
</style>
+39 -113
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { getFullUrl } from '../lib/router.svelte'
import { routes } from '../lib/types/routes'
import { toast } from '../lib/toast.svelte'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import { ensureRequestUri, getRequestUriFromUrl, getOAuthRequestUri } from '../lib/oauth'
interface SsoProvider {
provider: string
@@ -16,9 +16,19 @@
let providers = $state<SsoProvider[]>([])
let loading = $state(true)
let initiating = $state<string | null>(null)
let initialized = false
onMount(() => {
fetchProviders()
$effect(() => {
if (!initialized) {
initialized = true
ensureRequestUri().then((requestUri) => {
if (!requestUri) return
fetchProviders()
}).catch((err) => {
console.error('Failed to ensure OAuth request URI:', err)
toast.error($_('common.error'))
})
}
})
async function fetchProviders() {
@@ -26,10 +36,11 @@
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
providers = data.providers || []
providers = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
}
} catch {
toast.error($_('common.error'))
} catch (err) {
console.error('Failed to fetch SSO providers:', err)
toast.error(err instanceof Error ? err.message : $_('common.error'))
} finally {
loading = false
}
@@ -37,9 +48,10 @@
async function initiateRegistration(provider: string) {
initiating = provider
let requestUri = getRequestUriFromUrl()
try {
const response = await fetch('/oauth/sso/initiate', {
let response = await fetch('/oauth/sso/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -48,14 +60,24 @@
body: JSON.stringify({
provider,
action: 'register',
request_uri: requestUri,
}),
})
const data = await response.json()
let data = await response.json()
if (!response.ok) {
toast.error(data.error_description || data.error || $_('common.error'))
initiating = null
console.log('SSO initiate failed, restarting OAuth flow', data)
try {
const newRequestUri = await getOAuthRequestUri('create')
const url = new URL(window.location.href)
url.searchParams.set('request_uri', newRequestUri)
window.location.href = url.toString()
} catch (e) {
console.error('Failed to restart OAuth flow:', e)
toast.error(data.message || data.error || $_('common.error'))
initiating = null
}
return
}
@@ -66,14 +88,15 @@
toast.error($_('common.error'))
initiating = null
} catch {
toast.error($_('common.error'))
} catch (err) {
console.error('SSO registration initiation failed:', err)
toast.error(err instanceof Error ? err.message : $_('common.error'))
initiating = null
}
}
</script>
<div class="register-sso-page">
<div class="page">
<header class="page-header">
<h1>{$_('register.title')}</h1>
<p class="subtitle">{$_('register.ssoSubtitle')}</p>
@@ -90,11 +113,11 @@
</div>
</div>
<AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} />
<AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} oauthRequestUri={getRequestUriFromUrl()} />
{#if loading}
<div class="loading">
<div class="spinner"></div>
<div class="spinner md"></div>
</div>
{:else if providers.length === 0}
<div class="no-providers">
@@ -132,90 +155,6 @@
</div>
<style>
.register-sso-page {
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h1 {
margin: 0 0 var(--space-3) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
.loading {
display: flex;
justify-content: center;
padding: var(--space-8);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-providers {
text-align: center;
padding: var(--space-8);
@@ -274,20 +213,7 @@
cursor: not-allowed;
}
.provider-name {
.provider-button .provider-name {
flex: 1;
}
.form-links {
margin-top: var(--space-8);
}
.link-text {
text-align: center;
color: var(--text-secondary);
}
.link-text a {
color: var(--accent);
}
</style>
+7 -16
View File
@@ -561,7 +561,7 @@
.did {
margin: var(--space-1) 0 0 0;
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
word-break: break-all;
@@ -583,7 +583,7 @@
}
.error-code {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
opacity: 0.9;
}
@@ -788,13 +788,13 @@
}
.rkey {
font-family: monospace;
font-family: var(--font-mono);
font-weight: var(--font-medium);
color: var(--accent);
}
.cid {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
}
@@ -804,7 +804,7 @@
padding: var(--space-2);
background: var(--bg-secondary);
border-radius: var(--radius-md);
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: pre-wrap;
@@ -855,11 +855,6 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.record-detail {
display: flex;
flex-direction: column;
@@ -889,7 +884,7 @@
}
.mono {
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
}
@@ -944,7 +939,7 @@
padding: var(--space-4);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-family: monospace;
font-family: var(--font-mono);
font-size: var(--text-sm);
background: var(--bg-input);
color: var(--text-primary);
@@ -1001,8 +996,4 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+3 -14
View File
@@ -118,7 +118,7 @@
const response = await fetch('/oauth/sso/providers')
if (response.ok) {
const data = await response.json()
ssoProviders = data.providers || []
ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
}
} catch {
ssoProviders = []
@@ -212,7 +212,7 @@
pendingAction = () => handleUnlinkAccount(id)
showReauthModal = true
} else {
toast.error(data.error_description || data.error || 'Failed to unlink account')
toast.error(data.error_description || data.message || data.error || 'Failed to unlink account')
}
unlinkingId = null
return
@@ -1167,7 +1167,7 @@
border-radius: var(--radius-md);
text-align: center;
font-size: var(--text-sm);
font-family: ui-monospace, monospace;
font-family: var(--font-mono);
}
.passkey-list {
@@ -1411,11 +1411,6 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@media (max-width: 900px) {
.skeleton-grid {
grid-template-columns: 1fr;
@@ -1523,12 +1518,6 @@
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: var(--text-secondary);
font-size: var(--text-sm);
-5
View File
@@ -201,11 +201,6 @@
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.sessions-list {
display: flex;
flex-direction: column;
+22
View File
@@ -57,6 +57,20 @@
let emailTokenRequired = $state(false)
let emailUpdateAuthorized = $state(false)
let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null)
let newEmailInUse = $state(false)
async function checkNewEmailInUse() {
if (!newEmail.trim() || !newEmail.includes('@')) {
newEmailInUse = false
return
}
try {
const result = await api.checkEmailInUse(newEmail.trim())
newEmailInUse = result.inUse
} catch {
newEmailInUse = false
}
}
let handleLoading = $state(false)
let newHandle = $state('')
let deleteLoading = $state(false)
@@ -543,10 +557,14 @@
id="new-email"
type="email"
bind:value={newEmail}
onblur={checkNewEmailInUse}
placeholder={$_('settings.newEmailPlaceholder')}
disabled={emailLoading || emailUpdateAuthorized}
required
/>
{#if newEmailInUse}
<p class="hint warning">{$_('settings.emailInUseWarning')}</p>
{/if}
</div>
<div class="actions">
<button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}>
@@ -565,10 +583,14 @@
id="new-email"
type="email"
bind:value={newEmail}
onblur={checkNewEmailInUse}
placeholder={$_('settings.newEmailPlaceholder')}
disabled={emailLoading}
required
/>
{#if newEmailInUse}
<p class="hint warning">{$_('settings.emailInUseWarning')}</p>
{/if}
</div>
<button type="submit" disabled={emailLoading || !newEmail.trim()}>
{emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
@@ -20,6 +20,16 @@
signal: boolean
}
interface RegistrationResult {
did: string
handle: string
redirectUrl: string
accessJwt?: string
refreshJwt?: string
appPassword?: string
appPasswordName?: string
}
let pending = $state<PendingRegistration | null>(null)
let loading = $state(true)
let submitting = $state(false)
@@ -38,9 +48,13 @@
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
let externalDid = $state('')
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
selfHostedDidWebEnabled: boolean
} | null>(null)
let commsChannels = $state<CommsChannelConfig>({
@@ -50,6 +64,11 @@
signal: false,
})
let showAppPassword = $state(false)
let registrationResult = $state<RegistrationResult | null>(null)
let appPasswordCopied = $state(false)
let appPasswordAcknowledged = $state(false)
function getToken(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('token')
@@ -70,6 +89,10 @@
return commsChannels[ch as keyof CommsChannelConfig] ?? false
}
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
const domain = serverInfo?.availableUserDomains?.[0]
@@ -89,6 +112,7 @@
serverInfo = {
availableUserDomains: data.availableUserDomains || [],
inviteCodeRequired: data.inviteCodeRequired ?? false,
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
}
if (data.commsChannels) {
commsChannels = {
@@ -191,6 +215,38 @@
}
}
function copyAppPassword() {
if (registrationResult?.appPassword) {
navigator.clipboard.writeText(registrationResult.appPassword)
appPasswordCopied = true
}
}
function proceedFromAppPassword() {
if (!registrationResult) return
if (registrationResult.accessJwt && registrationResult.refreshJwt) {
localStorage.setItem('accessJwt', registrationResult.accessJwt)
localStorage.setItem('refreshJwt', registrationResult.refreshJwt)
}
if (registrationResult.redirectUrl) {
if (registrationResult.redirectUrl.startsWith('/app/verify')) {
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
did: registrationResult.did,
handle: registrationResult.handle,
channel: verificationChannel,
}))
const url = new URL(registrationResult.redirectUrl, window.location.origin)
url.searchParams.set('handle', registrationResult.handle)
url.searchParams.set('channel', verificationChannel)
window.location.href = url.pathname + url.search
return
}
window.location.href = registrationResult.redirectUrl
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const token = getToken()
@@ -229,6 +285,8 @@
discord_id: discordId || null,
telegram_username: telegramUsername || null,
signal_number: signalNumber || null,
did_type: didType,
did: didType === 'web-external' ? externalDid.trim() : null,
}),
})
@@ -240,36 +298,34 @@
return
}
if (data.accessJwt && data.refreshJwt) {
localStorage.setItem('accessJwt', data.accessJwt)
localStorage.setItem('refreshJwt', data.refreshJwt)
registrationResult = {
did: data.did,
handle: data.handle,
redirectUrl: data.redirectUrl,
accessJwt: data.accessJwt,
refreshJwt: data.refreshJwt,
appPassword: data.appPassword,
appPasswordName: data.appPasswordName,
}
if (data.redirectUrl) {
if (data.redirectUrl.startsWith('/app/verify')) {
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
did: data.did,
handle: data.handle,
channel: verificationChannel,
}))
}
window.location.href = data.redirectUrl
return
if (registrationResult.appPassword) {
showAppPassword = true
submitting = false
} else {
proceedFromAppPassword()
}
toast.error($_('common.error'))
submitting = false
} catch {
toast.error($_('common.error'))
} catch (err) {
console.error('SSO registration failed:', err)
toast.error(err instanceof Error ? err.message : $_('common.error'))
submitting = false
}
}
</script>
<div class="sso-register-container">
<div class="page">
{#if loading}
<div class="loading">
<div class="spinner"></div>
<div class="spinner md"></div>
<p>{$_('common.loading')}</p>
</div>
{:else if error && !pending}
@@ -277,7 +333,40 @@
<div class="error-icon">!</div>
<h2>{$_('common.error')}</h2>
<p>{error}</p>
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
<a href="/app/oauth/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
</div>
{:else if showAppPassword && registrationResult}
<header class="page-header">
<h1>{$_('appPasswords.created')}</h1>
<p class="subtitle">{$_('appPasswords.createdMessage')}</p>
</header>
<div class="app-password-step">
<div class="warning-box">
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
<p>{$_('appPasswords.saveWarningMessage')}</p>
</div>
<div class="app-password-display">
<div class="app-password-label">
App Password for: <strong>{registrationResult.appPasswordName}</strong>
</div>
<code class="app-password-code">{registrationResult.appPassword}</code>
<button type="button" class="copy-btn" onclick={copyAppPassword}>
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
</button>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
<span>{$_('appPasswords.acknowledgeLabel')}</span>
</label>
</div>
<button onclick={proceedFromAppPassword} disabled={!appPasswordAcknowledged}>
{$_('common.continue')}
</button>
</div>
{:else if pending}
<header class="page-header">
@@ -404,6 +493,56 @@
</div>
</fieldset>
<fieldset>
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
@@ -438,177 +577,17 @@
</div>
<style>
.sso-register-container {
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-8);
}
.loading p {
color: var(--text-secondary);
}
.error-container {
text-align: center;
padding: var(--space-8);
}
.error-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--error-text);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin: 0 auto var(--space-4);
}
.error-container h2 {
margin-bottom: var(--space-2);
}
.error-container p {
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.back-link {
color: var(--accent);
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h1 {
margin: 0 0 var(--space-3) 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.form-section {
min-width: 0;
}
form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.contact-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.contact-fields .field {
margin-bottom: 0;
}
.hint.success {
color: var(--success-text);
}
.hint.error {
color: var(--error-text);
}
.info-panel {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: var(--space-6);
}
.info-panel h3 {
margin: 0 0 var(--space-4) 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
}
.info-list {
margin: 0;
padding-left: var(--space-5);
}
.info-list li {
margin-bottom: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.info-list li:last-child {
margin-bottom: 0;
}
.provider-info {
margin-bottom: var(--space-6);
}
.provider-badge {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.provider-details {
display: flex;
flex-direction: column;
}
.provider-name {
font-weight: var(--font-semibold);
}
.provider-username {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.required {
color: var(--error-text);
}
button[type="submit"] {
margin-top: var(--space-3);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+4 -77
View File
@@ -151,12 +151,12 @@
placeholder={$_('trustedDevices.deviceNamePlaceholder')}
/>
<div class="edit-actions">
<button class="btn-small btn-primary" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
<button class="btn-small btn-secondary" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
<button class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button>
<button class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button>
</div>
{:else}
<h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
<button class="btn-icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
<button class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}>
&#9998;
</button>
{/if}
@@ -192,7 +192,7 @@
</div>
<div class="device-actions">
<button class="btn-danger" onclick={() => handleRevoke(device.id)}>
<button class="sm danger-outline" onclick={() => handleRevoke(device.id)}>
{$_('trustedDevices.revoke')}
</button>
</div>
@@ -203,12 +203,6 @@
</div>
<style>
.page {
max-width: var(--width-lg);
margin: 0 auto;
padding: var(--space-7);
}
header {
margin-bottom: var(--space-7);
}
@@ -300,19 +294,6 @@
gap: var(--space-2);
}
.btn-icon {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: var(--space-1);
font-size: var(--text-base);
}
.btn-icon:hover {
color: var(--text-primary);
}
.device-details {
margin-bottom: var(--space-3);
}
@@ -338,63 +319,9 @@
border-top: 1px solid var(--border-color);
}
.btn-small {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-xs);
cursor: pointer;
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
border: none;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-secondary:hover {
background: var(--bg-secondary);
}
.btn-danger {
background: transparent;
border: 1px solid var(--error-border);
color: var(--error-text);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-sm);
}
.btn-danger:hover {
background: var(--error-bg);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.skeleton-card {
height: 100px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
+49 -1
View File
@@ -31,6 +31,7 @@
let successPurpose = $state<string | null>(null)
let successChannel = $state<string | null>(null)
let tokenFromUrl = $state(false)
let oauthRequestUri = $state<string | null>(null)
const auth = $derived(getAuthState())
@@ -70,6 +71,9 @@
}
} else {
mode = 'signup'
if (params.request_uri) {
oauthRequestUri = params.request_uri
}
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
@@ -83,6 +87,18 @@
pendingVerification = null
}
}
if (!pendingVerification && params.did && params.handle && params.channel) {
pendingVerification = {
did: unsafeAsDid(params.did),
handle: params.handle,
channel: params.channel,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({
did: params.did,
handle: params.handle,
channel: params.channel,
}))
}
}
})
@@ -93,6 +109,34 @@
}
})
let pollingVerification = false
$effect(() => {
if (mode === 'signup' && pendingVerification && !verificationCode.trim()) {
const currentPending = pendingVerification
const interval = setInterval(async () => {
if (pollingVerification || verificationCode.trim()) return
pollingVerification = true
try {
const result = await api.checkEmailVerified(currentPending.did)
if (result.verified) {
clearInterval(interval)
clearPendingVerification()
if (oauthRequestUri) {
navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } })
} else {
navigate(routes.login)
}
}
} catch {
} finally {
pollingVerification = false
}
}, 3000)
return () => clearInterval(interval)
}
return undefined
})
function clearPendingVerification() {
localStorage.removeItem(STORAGE_KEY)
pendingVerification = null
@@ -108,7 +152,11 @@
try {
await confirmSignup(pendingVerification.did, verificationCode.trim())
clearPendingVerification()
navigate('/dashboard')
if (oauthRequestUri) {
navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } })
} else {
navigate(routes.dashboard)
}
} catch (e) {
error = e instanceof Error ? e.message : 'Verification failed'
} finally {
+372
View File
@@ -194,6 +194,38 @@ button.ghost:hover:not(:disabled) {
color: var(--text-primary);
}
button.link {
background: none;
border: none;
color: var(--accent);
padding: var(--space-2);
font-size: var(--text-sm);
font-weight: var(--font-normal);
}
button.link:hover:not(:disabled) {
background: none;
text-decoration: underline;
}
button.sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
}
button.icon {
background: none;
border: none;
color: var(--text-secondary);
padding: var(--space-1);
font-size: var(--text-base);
}
button.icon:hover:not(:disabled) {
background: none;
color: var(--text-primary);
}
label {
display: block;
font-size: var(--text-sm);
@@ -281,6 +313,10 @@ hr {
color: var(--error-text);
}
.hint.success {
color: var(--success-text);
}
.message {
padding: var(--space-4);
border-radius: var(--radius-md);
@@ -372,6 +408,32 @@ hr {
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h1 {
margin: 0 0 var(--space-3) 0;
}
.page-header .subtitle {
color: var(--text-secondary);
margin: 0;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-8);
}
.loading p {
color: var(--text-secondary);
margin: 0;
}
.back-link {
display: inline-block;
color: var(--text-secondary);
@@ -510,6 +572,11 @@ hr {
border-width: 2px;
}
.spinner.md {
width: 32px;
height: 32px;
}
.spinner.lg {
width: 60px;
height: 60px;
@@ -521,3 +588,308 @@ hr {
transform: rotate(360deg);
}
}
.skeleton {
background: var(--bg-secondary);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-card {
height: 100px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-line {
height: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.section-hint {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0 0 var(--space-5) 0;
}
.radio-group {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.radio-label {
display: flex;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-base);
font-weight: var(--font-normal);
margin-bottom: 0;
}
.radio-label input[type="radio"] {
margin-top: var(--space-1);
width: auto;
}
.radio-content {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.radio-hint {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.radio-label.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-hint.disabled-hint {
color: var(--warning-text);
}
.warning-box {
margin-top: var(--space-5);
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-3);
color: var(--warning-text);
}
.warning-box ul {
margin: var(--space-4) 0 0 0;
padding-left: var(--space-5);
}
.warning-box li {
margin-bottom: var(--space-3);
line-height: var(--leading-normal);
}
.warning-box li:last-child {
margin-bottom: 0;
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
.app-password-step {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: var(--width-md);
margin: 0 auto;
}
.app-password-step .warning-box {
margin-top: 0;
}
.app-password-step .warning-box p {
margin: 0;
color: var(--warning-text);
}
.app-password-display {
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.app-password-code {
display: block;
font-size: var(--text-xl);
font-family: var(--font-mono);
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
.form-section {
min-width: 0;
}
.form-links {
margin-top: var(--space-6);
}
.form-links .link-text {
text-align: center;
color: var(--text-secondary);
}
.form-links .link-text a {
color: var(--accent);
}
.contact-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.contact-fields .field {
margin-bottom: 0;
}
.provider-badge {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.provider-details {
display: flex;
flex-direction: column;
}
.provider-name {
font-weight: var(--font-semibold);
}
.provider-username {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.error-container {
text-align: center;
padding: var(--space-8);
}
.error-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--error-text);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
font-weight: var(--font-bold);
margin: 0 auto var(--space-4);
}
.error-container h2 {
margin-bottom: var(--space-2);
}
.error-container p {
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.info-list {
margin: 0;
padding-left: var(--space-5);
}
.info-list li {
margin-bottom: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.info-list li:last-child {
margin-bottom: 0;
}
.required {
color: var(--error-text);
}
-6
View File
@@ -445,12 +445,6 @@ label.auth-option {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.passkey-section {
margin-top: var(--space-5);
text-align: center;
+3
View File
@@ -48,6 +48,9 @@
--transition-normal: 0.15s ease;
--transition-slow: 0.25s ease;
--z-modal: 1000;
--overlay-bg: rgba(0, 0, 0, 0.5);
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
--bg-primary: #f9fafa;
@@ -12,8 +12,8 @@ import {
loadDPoPKey,
prepareWebAuthnCreationOptions,
saveDPoPKey,
} from "../../lib/migration/atproto-client";
import type { OAuthServerMetadata } from "../../lib/migration/types";
} from "../../lib/migration/atproto-client.ts";
import type { OAuthServerMetadata } from "../../lib/migration/types.ts";
const DPOP_KEY_STORAGE = "migration_dpop_key";
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte";
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte.ts";
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
+1 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlcOps, plcOps } from "../../lib/migration/plc-ops";
import { PlcOps, plcOps } from "../../lib/migration/plc-ops.ts";
describe("migration/plc-ops", () => {
beforeEach(() => {
@@ -83,6 +83,7 @@ function createInboundState(
authMethod: "password",
passkeySetupToken: null,
oauthCodeVerifier: null,
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
...overrides,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { MigrationError } from "../../lib/migration/types";
import { MigrationError } from "../../lib/migration/types.ts";
describe("migration/types", () => {
describe("MigrationError", () => {
+3 -1
View File
@@ -84,7 +84,9 @@ export function getErrorToasts(): string[] {
export { getToasts, toast };
function extractEndpoint(url: string): string {
const match = url.match(/\/xrpc\/([^?]+)/);
return match ? match[1] : url;
if (match) return match[1];
const pathOnly = url.split("?")[0];
return pathOnly;
}
export function setupFetchMock(): void {
globalThis.fetch = vi.fn(
@@ -0,0 +1,559 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/svelte";
import {
clearMocks,
jsonResponse,
mockData,
mockEndpoint,
setupFetchMock,
} from "./mocks.ts";
import { _testSetState } from "../lib/auth.svelte.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
describe("OAuth Registration Flow", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
sessionStorage.clear();
vi.restoreAllMocks();
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
Object.defineProperty(globalThis.location, "search", {
value: "",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/register",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "origin", {
value: "http://localhost:3000",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "href", {
value: "http://localhost:3000/app/register",
writable: true,
configurable: true,
});
_testSetState({
session: null,
loading: false,
error: null,
savedAccounts: [],
});
});
describe("startOAuthRegister", () => {
it("calls PAR endpoint with prompt=create", async () => {
let capturedBody: string | null = null;
mockEndpoint("/oauth/par", (_url, options) => {
capturedBody = options?.body as string;
return jsonResponse(
{ request_uri: "urn:mock:request", expires_in: 60 },
201,
);
});
const { startOAuthRegister } = await import("../lib/oauth.ts");
const hrefSetter = vi.fn();
Object.defineProperty(globalThis.location, "href", {
set: hrefSetter,
get: () => "http://localhost:3000/app/register",
configurable: true,
});
await startOAuthRegister();
expect(capturedBody).not.toBeNull();
const params = new URLSearchParams(capturedBody!);
expect(params.get("prompt")).toBe("create");
expect(params.get("response_type")).toBe("code");
expect(params.get("scope")).toContain("atproto");
});
it("redirects to authorize endpoint after PAR", async () => {
mockEndpoint("/oauth/par", () =>
jsonResponse(
{ request_uri: "urn:mock:test-request-uri", expires_in: 60 },
201,
));
const { startOAuthRegister } = await import("../lib/oauth.ts");
let redirectUrl: string | null = null;
Object.defineProperty(globalThis.location, "href", {
set: (url: string) => {
redirectUrl = url;
},
get: () => "http://localhost:3000/app/register",
configurable: true,
});
await startOAuthRegister();
expect(redirectUrl).not.toBeNull();
expect(redirectUrl).toContain("/oauth/authorize");
expect(redirectUrl).toContain("request_uri=");
});
});
describe("Register (passkey) component", () => {
it("adds request_uri to URL when none present", async () => {
mockEndpoint("/oauth/par", () =>
jsonResponse(
{ request_uri: "urn:mock:request", expires_in: 60 },
201,
));
let redirectUrl: string | null = null;
Object.defineProperty(globalThis.location, "href", {
set: (url: string) => {
redirectUrl = url;
},
get: () => "http://localhost:3000/app/register",
configurable: true,
});
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(
() => {
expect(redirectUrl).not.toBeNull();
},
{ timeout: 2000 },
);
expect(redirectUrl).toContain("request_uri=");
});
it("shows loading state while fetching request_uri", async () => {
mockEndpoint("/oauth/par", () =>
jsonResponse(
{ request_uri: "urn:mock:request", expires_in: 60 },
201,
));
Object.defineProperty(globalThis.location, "href", {
set: () => {},
get: () => "http://localhost:3000/app/register",
configurable: true,
});
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(() => {
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
});
it("logs error if OAuth initiation fails", async () => {
mockEndpoint(
"/oauth/par",
() =>
jsonResponse({
error: "invalid_request",
error_description: "Test error",
}, 400),
);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(
() => {},
);
Object.defineProperty(globalThis.location, "href", {
set: () => {},
get: () => "http://localhost:3000/app/register",
configurable: true,
});
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(
() => {
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to ensure OAuth request URI"),
expect.anything(),
);
},
{ timeout: 2000 },
);
consoleSpy.mockRestore();
});
});
describe("RegisterPassword component", () => {
it("adds request_uri to URL when none present", async () => {
mockEndpoint("/oauth/par", () =>
jsonResponse(
{ request_uri: "urn:mock:request", expires_in: 60 },
201,
));
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/oauth/register-password",
writable: true,
configurable: true,
});
let redirectUrl: string | null = null;
Object.defineProperty(globalThis.location, "href", {
set: (url: string) => {
redirectUrl = url;
},
get: () => "http://localhost:3000/app/oauth/register-password",
configurable: true,
});
const RegisterPassword =
(await import("../routes/RegisterPassword.svelte")).default;
render(RegisterPassword);
await waitFor(
() => {
expect(redirectUrl).not.toBeNull();
},
{ timeout: 2000 },
);
expect(redirectUrl).toContain("request_uri=");
});
it("renders form when request_uri is present", async () => {
Object.defineProperty(globalThis.location, "search", {
value: "?request_uri=urn:mock:test-request",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/oauth/register-password",
writable: true,
configurable: true,
});
mockEndpoint(
"com.atproto.server.describeServer",
() => jsonResponse(mockData.describeServer()),
);
mockEndpoint(
"/oauth/sso/providers",
() => jsonResponse({ providers: [] }),
);
const RegisterPassword =
(await import("../routes/RegisterPassword.svelte")).default;
render(RegisterPassword);
await waitFor(() => {
expect(screen.getByLabelText(/handle/i)).toBeInTheDocument();
});
});
});
describe("RegisterSso component", () => {
it("adds request_uri to URL when none present", async () => {
mockEndpoint("/oauth/par", () =>
jsonResponse(
{ request_uri: "urn:mock:request", expires_in: 60 },
201,
));
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/register-sso",
writable: true,
configurable: true,
});
let redirectUrl: string | null = null;
Object.defineProperty(globalThis.location, "href", {
set: (url: string) => {
redirectUrl = url;
},
get: () => "http://localhost:3000/app/register-sso",
configurable: true,
});
const RegisterSso =
(await import("../routes/RegisterSso.svelte")).default;
render(RegisterSso);
await waitFor(
() => {
expect(redirectUrl).not.toBeNull();
},
{ timeout: 2000 },
);
expect(redirectUrl).toContain("request_uri=");
});
it("renders SSO providers when request_uri is present", async () => {
Object.defineProperty(globalThis.location, "search", {
value: "?request_uri=urn:mock:test-request",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/register-sso",
writable: true,
configurable: true,
});
mockEndpoint("/oauth/sso/providers", () =>
jsonResponse({
providers: [{ provider: "google", name: "Google", icon: "google" }],
}));
const RegisterSso =
(await import("../routes/RegisterSso.svelte")).default;
render(RegisterSso);
await waitFor(() => {
expect(screen.getByText(/google/i)).toBeInTheDocument();
});
});
it("passes request_uri when initiating SSO registration", async () => {
Object.defineProperty(globalThis.location, "search", {
value: "?request_uri=urn:mock:test-request-uri",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/register-sso",
writable: true,
configurable: true,
});
mockEndpoint("/oauth/sso/providers", () =>
jsonResponse({
providers: [{ provider: "google", name: "Google", icon: "google" }],
}));
let capturedBody: string | null = null;
mockEndpoint("/oauth/sso/initiate", (_url, options) => {
capturedBody = options?.body as string;
return jsonResponse({ redirect_url: "https://google.com/oauth" });
});
Object.defineProperty(globalThis.location, "href", {
set: () => {},
get: () =>
"http://localhost:3000/app/register-sso?request_uri=urn:mock:test-request-uri",
configurable: true,
});
const RegisterSso =
(await import("../routes/RegisterSso.svelte")).default;
render(RegisterSso);
await waitFor(() => {
expect(screen.getByText(/google/i)).toBeInTheDocument();
});
const googleButton = screen.getByRole("button", { name: /google/i });
googleButton.click();
await waitFor(() => {
expect(capturedBody).not.toBeNull();
});
const body = JSON.parse(capturedBody!);
expect(body.request_uri).toBe("urn:mock:test-request-uri");
expect(body.action).toBe("register");
});
});
describe("AccountTypeSwitcher with OAuth context", () => {
it("preserves request_uri in links when oauthRequestUri is provided", async () => {
const AccountTypeSwitcher = (
await import("../components/AccountTypeSwitcher.svelte")
).default;
render(AccountTypeSwitcher, {
props: {
active: "passkey",
ssoAvailable: true,
oauthRequestUri: "urn:mock:test-request-uri",
},
});
const passwordLink = screen.getByText(/password/i).closest("a");
const ssoLink = screen.getByText(/sso/i).closest("a");
expect(passwordLink?.getAttribute("href")).toContain("request_uri=");
expect(passwordLink?.getAttribute("href")).toContain(
encodeURIComponent("urn:mock:test-request-uri"),
);
expect(ssoLink?.getAttribute("href")).toContain("request_uri=");
});
it("uses oauth routes without request_uri when no oauthRequestUri provided", async () => {
const AccountTypeSwitcher = (
await import("../components/AccountTypeSwitcher.svelte")
).default;
render(AccountTypeSwitcher, {
props: {
active: "passkey",
ssoAvailable: true,
},
});
const passwordLink = screen.getByText(/password/i).closest("a");
expect(passwordLink?.getAttribute("href")).toBe(
"/app/oauth/register-password",
);
expect(passwordLink?.getAttribute("href")).not.toContain("request_uri=");
});
it("passkey link goes to oauth/register when in OAuth context", async () => {
const AccountTypeSwitcher = (
await import("../components/AccountTypeSwitcher.svelte")
).default;
render(AccountTypeSwitcher, {
props: {
active: "password",
ssoAvailable: true,
oauthRequestUri: "urn:mock:test-request-uri",
},
});
const passkeyLink = screen.getByText(/passkey/i).closest("a");
expect(passkeyLink?.getAttribute("href")).toContain("/oauth/register");
expect(passkeyLink?.getAttribute("href")).toContain("request_uri=");
});
});
describe("Register component (OAuth context)", () => {
beforeEach(() => {
Object.defineProperty(globalThis.location, "search", {
value: "?request_uri=urn:mock:test-request",
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.location, "pathname", {
value: "/app/oauth/register",
writable: true,
configurable: true,
});
mockEndpoint(
"com.atproto.server.describeServer",
() => jsonResponse(mockData.describeServer()),
);
mockEndpoint(
"/oauth/sso/providers",
() => jsonResponse({ providers: [] }),
);
mockEndpoint(
"/oauth/authorize",
() => jsonResponse({ client_name: "Test App" }),
);
});
it("renders registration form with AccountTypeSwitcher", async () => {
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(() => {
const switcher = document.querySelector(".account-type-switcher");
expect(switcher).toBeInTheDocument();
expect(switcher?.textContent).toContain("Passkey");
expect(switcher?.textContent).toContain("Password");
});
});
it("displays client name in subtitle when available", async () => {
mockEndpoint(
"/oauth/authorize",
() => jsonResponse({ client_name: "Awesome App" }),
);
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(() => {
expect(screen.getByText(/awesome app/i)).toBeInTheDocument();
});
});
it("shows handle input field", async () => {
const Register = (await import("../routes/Register.svelte"))
.default;
render(Register);
await waitFor(() => {
expect(screen.getByLabelText(/handle/i)).toBeInTheDocument();
});
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { init, register, waitLocale } from "svelte-i18n";
import { _testResetState } from "../lib/auth.svelte";
import { _testResetState } from "../lib/auth.svelte.ts";
register("en", () => import("../locales/en.json"));
@@ -0,0 +1 @@
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;