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