Compare commits

...
12 Commits
103 changed files with 3296 additions and 8301 deletions
Generated
+20 -20
View File
@@ -6094,7 +6094,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"anyhow",
"axum",
@@ -6142,7 +6142,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"anyhow",
"base32",
@@ -6165,7 +6165,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6179,7 +6179,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"confique",
"serde",
@@ -6202,7 +6202,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -6218,7 +6218,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"chrono",
@@ -6235,7 +6235,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6251,7 +6251,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"bytes",
@@ -6262,7 +6262,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"chrono",
"hickory-resolver",
@@ -6280,7 +6280,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"anyhow",
"axum",
@@ -6303,7 +6303,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"axum",
"base64 0.22.1",
@@ -6336,7 +6336,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6424,7 +6424,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"bytes",
"cid",
@@ -6436,7 +6436,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"backon",
@@ -6461,7 +6461,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"axum",
"futures",
@@ -6477,7 +6477,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"axum",
"clap",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"async-trait",
"aws-config",
@@ -6514,7 +6514,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"anyhow",
"axum",
@@ -6536,7 +6536,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.4.3"
version = "0.4.6"
dependencies = [
"chrono",
"cid",
+1 -1
View File
@@ -24,7 +24,7 @@ members = [
]
[workspace.package]
version = "0.4.4"
version = "0.4.6"
edition = "2024"
license = "AGPL-3.0-or-later"
+262
View File
@@ -0,0 +1,262 @@
use bcrypt::DEFAULT_COST;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tracing::error;
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::types::{AtIdentifier, Did, Handle};
pub struct ResolvedRepo {
pub user_id: uuid::Uuid,
pub did: Did,
pub handle: Handle,
}
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> {
let raw = handle.as_str();
let qualified = match raw.contains('.') {
true => return Ok(handle.clone()),
false => format!(
"{}.{}",
raw,
tranquil_config::get().server.hostname_without_port()
),
};
qualified
.parse()
.map_err(|_| ApiError::InvalidRequest("Invalid handle format".into()))
}
pub async fn resolve_repo(
user_repo: &dyn UserRepository,
repo: &AtIdentifier,
) -> Result<ResolvedRepo, ApiError> {
let row = match repo {
AtIdentifier::Did(did) => user_repo
.get_by_did(did)
.await
.log_db_err("resolving repo by DID")?,
AtIdentifier::Handle(handle) => {
let qualified = qualify_handle(handle)?;
user_repo
.get_by_handle(&qualified)
.await
.log_db_err("resolving repo by handle")?
}
};
row.map(|r| ResolvedRepo {
user_id: r.id,
did: r.did,
handle: r.handle,
})
.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
}
pub async fn resolve_repo_user_id(
user_repo: &dyn UserRepository,
repo: &AtIdentifier,
) -> Result<uuid::Uuid, ApiError> {
let id = match repo {
AtIdentifier::Did(did) => user_repo
.get_id_by_did(did)
.await
.log_db_err("resolving repo user ID by DID")?,
AtIdentifier::Handle(handle) => {
let qualified = qualify_handle(handle)?;
user_repo
.get_id_by_handle(&qualified)
.await
.log_db_err("resolving repo user ID by handle")?
}
};
id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
}
pub fn group_invite_uses_by_code<U, F>(
uses: Vec<tranquil_db_traits::InviteCodeUse>,
map_use: F,
) -> HashMap<String, Vec<U>>
where
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
{
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
let code = u.code.clone();
acc.entry(code).or_default().push(map_use(u));
acc
})
}
pub fn resolve_also_known_as(
overrides: Option<&DidWebOverrides>,
current_handle: &str,
) -> Vec<String> {
overrides
.filter(|ovr| !ovr.also_known_as.is_empty())
.map(|ovr| ovr.also_known_as.clone())
.unwrap_or_else(|| vec![format!("at://{}", current_handle)])
}
pub fn build_did_document(
did: &str,
also_known_as: Vec<String>,
verification_methods: Vec<serde_json::Value>,
service_endpoint: &str,
) -> serde_json::Value {
serde_json::json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": verification_methods,
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
})
}
pub async fn set_channel_verified_flag(
user_repo: &dyn UserRepository,
user_id: uuid::Uuid,
channel: CommsChannel,
) -> Result<(), ApiError> {
match channel {
CommsChannel::Email => user_repo
.set_email_verified_flag(user_id)
.await
.log_db_err("updating email verified status")?,
CommsChannel::Discord => user_repo
.set_discord_verified_flag(user_id)
.await
.log_db_err("updating discord verified status")?,
CommsChannel::Telegram => user_repo
.set_telegram_verified_flag(user_id)
.await
.log_db_err("updating telegram verified status")?,
CommsChannel::Signal => user_repo
.set_signal_verified_flag(user_id)
.await
.log_db_err("updating signal verified status")?,
};
Ok(())
}
pub struct ChannelInput<'a> {
pub email: Option<&'a str>,
pub discord_username: Option<&'a str>,
pub telegram_username: Option<&'a str>,
pub signal_username: Option<&'a str>,
}
pub fn extract_verification_recipient(
channel: CommsChannel,
input: &ChannelInput<'_>,
) -> Result<String, ApiError> {
match channel {
CommsChannel::Email => match input.email {
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
_ => Err(ApiError::MissingEmail),
},
CommsChannel::Discord => match input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
return Err(ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
));
}
Ok(clean)
}
_ => Err(ApiError::MissingDiscordId),
},
CommsChannel::Telegram => match input.telegram_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
return Err(ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
));
}
Ok(clean.to_string())
}
_ => Err(ApiError::MissingTelegramUsername),
},
CommsChannel::Signal => match input.signal_username {
Some(username) if !username.trim().is_empty() => {
Ok(username.trim().trim_start_matches('@').to_lowercase())
}
_ => Err(ApiError::MissingSignalNumber),
},
}
}
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
return Err(ApiError::SelfHostedDidWebDisabled);
}
let encoded_handle = handle.replace(':', "%3A");
Ok(format!("did:web:{}", encoded_handle))
}
pub enum CredentialMatch {
MainPassword,
AppPassword {
name: String,
scopes: Option<String>,
controller_did: Option<Did>,
},
}
pub async fn verify_credential(
session_repo: &dyn SessionRepository,
user_id: uuid::Uuid,
password: &str,
password_hash: Option<&str>,
) -> Option<CredentialMatch> {
let main_valid = password_hash
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
.unwrap_or(false);
if main_valid {
return Some(CredentialMatch::MainPassword);
}
let app_passwords = session_repo
.get_app_passwords_for_login(user_id)
.await
.unwrap_or_default();
app_passwords
.into_iter()
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
.map(|app| CredentialMatch::AppPassword {
name: app.name,
scopes: app.scopes,
controller_did: app.created_by_controller_did,
})
}
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
error!("Bcrypt hash error: {:?}", e);
ApiError::InternalError(None)
})
}
pub fn validate_token_hash(
expires_at: Option<DateTime<Utc>>,
stored_hash: &str,
input_token: &str,
expired_err: ApiError,
invalid_err: ApiError,
) -> Result<(), ApiError> {
match expires_at {
Some(exp) if exp < Utc::now() => Err(expired_err),
_ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) {
true => Ok(()),
false => Err(invalid_err),
},
}
}
+15 -12
View File
@@ -153,9 +153,7 @@ pub async fn create_account(
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = if is_migration {
None
} else {
let verification_recipient = {
Some(match verification_channel {
tranquil_db_traits::CommsChannel::Email => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
@@ -372,9 +370,11 @@ pub async fn create_account(
return ApiError::InternalError(None).into_response();
}
let hostname = &tranquil_config::get().server.hostname;
let verification_required = if let Some(ref user_email) = email {
let verification_required = if let Some(ref recipient) = verification_recipient {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_typed, user_email,
&did_typed,
verification_channel,
recipient,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
@@ -382,13 +382,14 @@ pub async fn create_account(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
reactivated.user_id,
user_email,
verification_channel,
recipient,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
warn!("Failed to enqueue migration verification: {:?}", e);
}
true
} else {
@@ -403,7 +404,7 @@ pub async fn create_account(
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
verification_channel: tranquil_db_traits::CommsChannel::Email,
verification_channel,
}),
)
.into_response();
@@ -668,10 +669,11 @@ pub async fn create_account(
);
}
}
} else if let Some(ref user_email) = email {
} else if let Some(ref recipient) = verification_recipient {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_for_commit,
user_email,
verification_channel,
recipient,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
@@ -679,13 +681,14 @@ pub async fn create_account(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
user_email,
verification_channel,
recipient,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
warn!("Failed to enqueue migration verification: {:?}", e);
}
}
+2 -6
View File
@@ -1,6 +1,7 @@
pub mod actor;
pub mod admin;
pub mod age_assurance;
pub mod common;
pub mod delegation;
pub mod discord_webhook;
pub mod identity;
@@ -10,7 +11,6 @@ pub mod repo;
pub mod server;
pub mod telegram_webhook;
pub mod temp;
pub mod verification;
use tranquil_pds::state::AppState;
@@ -215,10 +215,6 @@ pub fn api_routes() -> axum::Router<AppState> {
"/_account.checkEmailInUse",
post(server::check_email_in_use),
)
.route(
"/_account.checkCommsChannelInUse",
post(server::check_comms_channel_in_use),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(server::reserve_signing_key),
@@ -390,7 +386,7 @@ pub fn api_routes() -> axum::Router<AppState> {
)
.route(
"/_account.confirmChannelVerification",
post(verification::confirm_channel_verification),
post(server::confirm_channel_verification),
)
.route("/_account.verifyToken", post(server::verify_token))
.route(
-34
View File
@@ -606,37 +606,3 @@ pub async fn check_email_in_use(
}))
.into_response()
}
#[derive(Deserialize)]
pub struct CheckCommsChannelInUseInput {
pub channel: CommsChannel,
pub identifier: String,
}
pub async fn check_comms_channel_in_use(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckCommsChannelInUseInput>,
) -> Response {
let identifier = input.identifier.trim();
if identifier.is_empty() {
return ApiError::InvalidRequest("identifier is required".into()).into_response();
}
let count = match state
.user_repo
.count_accounts_by_comms_identifier(input.channel, identifier)
.await
{
Ok(c) => c,
Err(e) => {
error!("DB error checking comms channel usage: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
Json(json!({
"inUse": count > 0,
}))
.into_response()
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub use account_status::{
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{
authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use,
authorize_email_update, check_channel_verified, check_email_in_use,
check_email_update_status, check_email_verified, confirm_email, request_email_update,
update_email,
};
+16 -7
View File
@@ -40,7 +40,8 @@ pub async fn verify_migration_email(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendMigrationVerificationInput {
pub email: String,
pub channel: Option<tranquil_db_traits::CommsChannel>,
pub identifier: String,
}
#[derive(Serialize)]
@@ -53,9 +54,12 @@ pub async fn resend_migration_verification(
State(state): State<AppState>,
Json(input): Json<ResendMigrationVerificationInput>,
) -> Result<Json<ResendMigrationVerificationOutput>, ApiError> {
let email = input.email.trim().to_lowercase();
let channel = input
.channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let identifier = input.identifier.trim().to_lowercase();
let user = match state.user_repo.get_by_email(&email).await {
let user = match state.user_repo.get_by_email(&identifier).await {
Ok(Some(u)) => u,
Ok(None) => {
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
@@ -71,23 +75,28 @@ pub async fn resend_migration_verification(
}
let hostname = &tranquil_config::get().server.hostname;
let token = tranquil_pds::auth::verification_token::generate_migration_token(&user.did, &email);
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&user.did,
channel,
&identifier,
);
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
&email,
channel,
&identifier,
&formatted_token,
hostname,
)
.await
{
warn!(error = ?e, "Failed to enqueue migration verification email");
warn!(error = ?e, channel = ?channel, "Failed to enqueue migration verification");
}
info!(did = %user.did, "Resent migration verification email");
info!(did = %user.did, channel = ?channel, "Resent migration verification");
Ok(Json(ResendMigrationVerificationOutput { sent: true }))
}
+36 -16
View File
@@ -72,10 +72,6 @@ async fn handle_migration_verification(
channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
if channel != CommsChannel::Email {
return Err(ApiError::InvalidChannel);
}
let user = state
.user_repo
.get_verification_info(did)
@@ -83,19 +79,43 @@ async fn handle_migration_verification(
.log_db_err("during migration verification")?
.ok_or(ApiError::AccountNotFound)?;
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err(ApiError::IdentifierMismatch);
}
match channel {
CommsChannel::Email => {
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err(ApiError::IdentifierMismatch);
}
if !user.channel_verification.email {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email_verified status")?;
}
}
CommsChannel::Discord => {
state
.user_repo
.set_discord_verified_flag(user.id)
.await
.log_db_err("updating discord verified status")?;
}
CommsChannel::Telegram => {
state
.user_repo
.set_telegram_verified_flag(user.id)
.await
.log_db_err("updating telegram verified status")?;
}
CommsChannel::Signal => {
state
.user_repo
.set_signal_verified_flag(user.id)
.await
.log_db_err("updating signal verified status")?;
}
};
if !user.channel_verification.email {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email_verified status")?;
}
info!(did = %did, "Migration email verified successfully");
info!(did = %did, channel = ?channel, "Migration verification completed successfully");
Ok(Json(VerifyTokenOutput {
success: true,
-6
View File
@@ -429,12 +429,6 @@ pub trait UserRepository: Send + Sync {
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>;
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError>;
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError>;
async fn set_password_reset_code(
-27
View File
@@ -1654,33 +1654,6 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)
}
async fn count_accounts_by_comms_identifier(
&self,
channel: CommsChannel,
identifier: &str,
) -> Result<i64, DbError> {
let query = match channel {
CommsChannel::Email => {
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Discord => {
"SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Telegram => {
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Signal => {
"SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL"
}
};
sqlx::query_scalar(query)
.bind(identifier)
.fetch_one(&self.pool)
.await
.map(|c: Option<i64>| c.unwrap_or(0))
.map_err(map_sqlx_error)
}
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
sqlx::query_scalar!(
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
@@ -323,9 +323,16 @@ pub async fn authorize_get(
.await
&& !accounts.is_empty()
{
let login_hint_param = request_data
.parameters
.login_hint
.as_ref()
.map(|h| format!("&login_hint={}", url_encode(h)))
.unwrap_or_default();
return redirect_see_other(&format!(
"/app/oauth/accounts?request_uri={}",
url_encode(&request_uri)
"/app/oauth/accounts?request_uri={}{}",
url_encode(&request_uri),
login_hint_param
));
}
redirect_see_other(&format!(
+179 -98
View File
@@ -9,8 +9,7 @@ use std::borrow::Cow;
#[derive(Debug, Serialize)]
struct ErrorBody<'a> {
error: Cow<'a, str>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
message: String,
}
#[derive(Debug)]
@@ -113,6 +112,12 @@ pub enum ApiError {
SsoLinkNotFound,
AuthFactorTokenRequired,
LegacyLoginBlocked,
ReauthRequired {
methods: Vec<String>,
},
MfaVerificationRequiredWithMethods {
methods: Vec<String>,
},
}
impl ApiError {
@@ -132,7 +137,8 @@ impl ApiError {
| Self::InvalidPassword(_)
| Self::InvalidToken(_)
| Self::PasskeyCounterAnomaly
| Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED,
| Self::OAuthExpiredToken(_)
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
Self::Forbidden
@@ -143,6 +149,7 @@ impl ApiError {
| Self::AccountMigrated
| Self::AccountNotVerified
| Self::MfaVerificationRequired
| Self::MfaVerificationRequiredWithMethods { .. }
| Self::AuthorizationError(_) => StatusCode::FORBIDDEN,
Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
@@ -214,7 +221,7 @@ impl ApiError {
}
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalError"),
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
Cow::Borrowed("UpstreamError")
}
@@ -311,24 +318,53 @@ impl ApiError {
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"),
Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"),
Self::ReauthRequired { .. } => Cow::Borrowed("ReauthRequired"),
Self::MfaVerificationRequiredWithMethods { .. } => {
Cow::Borrowed("MfaVerificationRequired")
}
}
}
fn message(&self) -> Option<String> {
fn message(&self) -> String {
match self {
Self::InternalError(msg)
| Self::AuthenticationFailed(msg)
| Self::InvalidToken(msg)
| Self::ExpiredToken(msg)
| Self::OAuthExpiredToken(msg)
| Self::RepoNotFound(msg)
| Self::BlobNotFound(msg)
| Self::InvalidHandle(msg)
| Self::HandleNotAvailable(msg)
| Self::InvalidSwap(msg)
| Self::InsufficientScope(msg)
| Self::InvalidCode(msg)
| Self::RateLimitExceeded(msg)
| Self::ServiceUnavailable(msg) => msg.clone(),
Self::InternalError(msg) => msg
.clone()
.unwrap_or_else(|| "Internal Server Error".into()),
Self::AuthenticationFailed(msg) => msg
.clone()
.unwrap_or_else(|| "Authentication failed".into()),
Self::InvalidToken(msg) => {
msg.clone().unwrap_or_else(|| "Invalid token".into())
}
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
msg.clone().unwrap_or_else(|| "Token has expired".into())
}
Self::RepoNotFound(msg) => msg
.clone()
.unwrap_or_else(|| "Repository not found".into()),
Self::BlobNotFound(msg) => {
msg.clone().unwrap_or_else(|| "Blob not found".into())
}
Self::InvalidHandle(msg) => {
msg.clone().unwrap_or_else(|| "Invalid handle".into())
}
Self::HandleNotAvailable(msg) => msg
.clone()
.unwrap_or_else(|| "Handle not available".into()),
Self::InvalidSwap(msg) => {
msg.clone().unwrap_or_else(|| "Invalid swap".into())
}
Self::InsufficientScope(msg) => msg
.clone()
.unwrap_or_else(|| "Insufficient scope".into()),
Self::InvalidCode(msg) => {
msg.clone().unwrap_or_else(|| "Invalid code".into())
}
Self::RateLimitExceeded(msg) => msg
.clone()
.unwrap_or_else(|| "Rate limit exceeded".into()),
Self::ServiceUnavailable(msg) => msg
.clone()
.unwrap_or_else(|| "Service temporarily unavailable".into()),
Self::InvalidRequest(msg)
| Self::UpstreamUnavailable(msg)
| Self::InvalidPassword(msg)
@@ -336,109 +372,123 @@ impl ApiError {
| Self::InvalidRecord(msg)
| Self::NotFoundMsg(msg)
| Self::UpstreamErrorMsg(msg)
| Self::PayloadTooLarge(msg) => Some(msg.clone()),
Self::AccountMigrated => Some(
"Account has been migrated to another PDS. Repo operations are not allowed."
.to_string(),
),
Self::AccountNotVerified => Some(
"You must verify at least one notification channel before creating records"
.to_string(),
),
Self::NoPasskeys => {
Some("No passkeys registered for this account".to_string())
| Self::PayloadTooLarge(msg)
| Self::InvalidScopes(msg)
| Self::InvalidDelegation(msg)
| Self::AuthorizationError(msg)
| Self::InvalidDid(msg) => msg.clone(),
Self::UpstreamError { message, .. } => message
.clone()
.unwrap_or_else(|| "Upstream error".into()),
Self::DatabaseError => "Internal Server Error".into(),
Self::AuthenticationRequired => "Authentication required".into(),
Self::TokenRequired => "Authentication token required".into(),
Self::AccountDeactivated => "Account is deactivated".into(),
Self::AccountTakedown => "Account has been taken down".into(),
Self::AccountNotFound => "Account not found".into(),
Self::RecordNotFound => "Record not found".into(),
Self::Forbidden => "Forbidden".into(),
Self::InvitesDisabled => "Invite codes are disabled on this server".into(),
Self::InvalidCollection => "Invalid collection".into(),
Self::InvalidChannel => "Invalid notification channel".into(),
Self::TotpAlreadyEnabled => "TOTP is already enabled".into(),
Self::TotpNotEnabled => "TOTP is not enabled".into(),
Self::DuplicateAppPassword => "An app password with this name already exists".into(),
Self::AppPasswordNotFound => "App password not found".into(),
Self::SessionNotFound => "Session not found".into(),
Self::UpstreamFailure => "Upstream service failed".into(),
Self::RepoTakendown => "Repository has been taken down".into(),
Self::RepoDeactivated => "Repository is deactivated".into(),
Self::AccountMigrated => {
"Account has been migrated to another PDS. Repo operations are not allowed.".into()
}
Self::NoChallengeInProgress => Some(
"No passkey authentication in progress or challenge expired".to_string(),
),
Self::InvalidCredential => Some("Failed to parse credential response".to_string()),
Self::NoRegistrationInProgress => Some(
"No registration in progress. Call startPasskeyRegistration first.".to_string(),
),
Self::RegistrationFailed => {
Some("Failed to verify passkey registration".to_string())
Self::AccountNotVerified => {
"You must verify at least one notification channel before creating records".into()
}
Self::PasskeyNotFound => Some("Passkey not found".to_string()),
Self::InvalidId => Some("Invalid ID format".to_string()),
Self::InvalidScopes(msg) | Self::InvalidDelegation(msg) => Some(msg.clone()),
Self::ControllerNotFound => Some("Controller account not found".to_string()),
Self::NoPasskeys => "No passkeys registered for this account".into(),
Self::NoChallengeInProgress => {
"No passkey authentication in progress or challenge expired".into()
}
Self::InvalidCredential => "Failed to parse credential response".into(),
Self::NoRegistrationInProgress => {
"No registration in progress. Call startPasskeyRegistration first.".into()
}
Self::RegistrationFailed => "Failed to verify passkey registration".into(),
Self::PasskeyNotFound => "Passkey not found".into(),
Self::InvalidId => "Invalid ID format".into(),
Self::ControllerNotFound => "Controller account not found".into(),
Self::DelegationNotFound => {
Some("No active delegation found for this controller".to_string())
"No active delegation found for this controller".into()
}
Self::InviteCodeRequired => {
Some("An invite code is required to create an account".to_string())
"An invite code is required to create an account".into()
}
Self::RepoNotReady => Some("Repository not ready".to_string()),
Self::PasskeyCounterAnomaly => Some(
"Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".to_string(),
),
Self::MfaVerificationRequired => Some(
"This sensitive operation requires MFA verification".to_string(),
),
Self::DeviceNotFound => Some("Device not found".to_string()),
Self::NoEmail => Some("Recipient has no email address".to_string()),
Self::AuthorizationError(msg) | Self::InvalidDid(msg) => Some(msg.clone()),
Self::RepoNotReady => "Repository not ready".into(),
Self::PasskeyCounterAnomaly => {
"Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".into()
}
Self::MfaVerificationRequired => {
"This sensitive operation requires MFA verification".into()
}
Self::DeviceNotFound => "Device not found".into(),
Self::NoEmail => "Recipient has no email address".into(),
Self::InvalidSigningKey => {
Some("Signing key not found, already used, or expired".to_string())
}
Self::SetupExpired => {
Some("Setup has already been completed or expired".to_string())
}
Self::InvalidAccount => {
Some("This account is not a passkey-only account".to_string())
}
Self::InvalidRecoveryLink => Some("Invalid recovery link".to_string()),
Self::RecoveryLinkExpired => Some("Recovery link has expired".to_string()),
Self::MissingEmail => {
Some("Email is required when using email verification".to_string())
"Signing key not found, already used, or expired".into()
}
Self::SetupExpired => "Setup has already been completed or expired".into(),
Self::InvalidAccount => "This account is not a passkey-only account".into(),
Self::InvalidRecoveryLink => "Invalid recovery link".into(),
Self::RecoveryLinkExpired => "Recovery link has expired".into(),
Self::MissingEmail => "Email is required when using email verification".into(),
Self::MissingDiscordId => {
Some("Discord ID is required when using Discord verification".to_string())
"Discord ID is required when using Discord verification".into()
}
Self::MissingTelegramUsername => {
Some("Telegram username is required when using Telegram verification".to_string())
"Telegram username is required when using Telegram verification".into()
}
Self::MissingSignalNumber => {
Some("Signal username is required when using Signal verification".to_string())
"Signal username is required when using Signal verification".into()
}
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
Self::InvalidVerificationChannel => "Invalid verification channel".into(),
Self::SelfHostedDidWebDisabled => {
Some("Self-hosted did:web accounts are disabled on this server".to_string())
}
Self::AccountAlreadyExists => Some("Account already exists".to_string()),
Self::HandleNotFound => Some("Unable to resolve handle".to_string()),
Self::SubjectNotFound => Some("Subject not found".to_string()),
Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()),
Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()),
Self::SsoInvalidAction => {
Some("Action must be login, link, or register".to_string())
"Self-hosted did:web accounts are disabled on this server".into()
}
Self::AccountAlreadyExists => "Account already exists".into(),
Self::HandleNotFound => "Unable to resolve handle".into(),
Self::SubjectNotFound => "Subject not found".into(),
Self::SsoProviderNotFound => "Unknown SSO provider".into(),
Self::SsoProviderNotEnabled => "SSO provider is not enabled".into(),
Self::SsoInvalidAction => "Action must be login, link, or register".into(),
Self::SsoNotAuthenticated => {
Some("Must be authenticated to link SSO account".to_string())
"Must be authenticated to link SSO account".into()
}
Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()),
Self::SsoSessionExpired => "SSO session expired or invalid".into(),
Self::SsoAlreadyLinked => {
Some("This SSO account is already linked to a different user".to_string())
"This SSO account is already linked to a different user".into()
}
Self::SsoLinkNotFound => Some("Linked account not found".to_string()),
Self::SsoLinkNotFound => "Linked account not found".into(),
Self::IdentifierMismatch => {
Some("The identifier does not match the verification token".to_string())
"The identifier does not match the verification token".into()
}
Self::UpstreamTimeout => "Upstream service timed out".into(),
Self::AdminRequired => "This action requires admin privileges".into(),
Self::EmailTaken => "This email address is already registered".into(),
Self::HandleTaken => "This handle is already taken".into(),
Self::InvalidEmail => "Please provide a valid email address".into(),
Self::InvalidInviteCode => "The invite code provided is invalid".into(),
Self::DuplicateCreate => "Account creation failed: duplicate request".into(),
Self::LegacyLoginBlocked => {
"This account requires MFA. Please use an OAuth client that supports TOTP verification.".into()
}
Self::UpstreamError { message, .. } => message.clone(),
Self::UpstreamTimeout => Some("Upstream service timed out".to_string()),
Self::AdminRequired => Some("This action requires admin privileges".to_string()),
Self::EmailTaken => Some("This email address is already registered".to_string()),
Self::HandleTaken => Some("This handle is already taken".to_string()),
Self::InvalidEmail => Some("Please provide a valid email address".to_string()),
Self::InvalidInviteCode => Some("The invite code provided is invalid".to_string()),
Self::DuplicateCreate => Some("Account creation failed: duplicate request".to_string()),
Self::LegacyLoginBlocked => Some(
"This account requires MFA. Please use an OAuth client that supports TOTP verification.".to_string(),
),
Self::AuthFactorTokenRequired => {
Some("A sign in code has been sent to your email address".to_string())
"A sign-in code has been sent to your email address".into()
}
Self::ReauthRequired { .. } => {
"Re-authentication required for this action".into()
}
Self::MfaVerificationRequiredWithMethods { .. } => {
"This sensitive operation requires MFA verification".into()
}
_ => None,
}
}
pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
@@ -467,6 +517,31 @@ impl ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
Self::ReauthRequired { ref methods } => {
return (
self.status_code(),
Json(serde_json::json!({
"error": "ReauthRequired",
"message": "Re-authentication required for this action",
"reauthMethods": methods,
})),
)
.into_response();
}
Self::MfaVerificationRequiredWithMethods { ref methods } => {
return (
self.status_code(),
Json(serde_json::json!({
"error": "MfaVerificationRequired",
"message": "This sensitive operation requires MFA verification",
"reauthMethods": methods,
})),
)
.into_response();
}
_ => {}
}
let body = ErrorBody {
error: self.error_name(),
message: self.message(),
@@ -562,6 +637,12 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
}
}
impl From<crate::auth::scope_verified::ScopeVerificationError> for ApiError {
fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self {
Self::InsufficientScope(Some(e.to_string()))
}
}
impl From<crate::handle::HandleResolutionError> for ApiError {
fn from(e: crate::handle::HandleResolutionError) -> Self {
match e {
+4 -2
View File
@@ -7,6 +7,8 @@ pub mod validation;
pub use error::ApiError;
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit};
pub use responses::{
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput,
EmptyResponse, EnabledResponse, HasPasswordResponse, InUseOutput, OptionsResponse,
PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse,
TokenRequiredResponse, VerifiedResponse,
};
+6 -6
View File
@@ -10,7 +10,7 @@ use axum::{
body::Bytes,
extract::{RawQuery, Request, State},
handler::Handler,
http::{HeaderMap, Method, StatusCode},
http::{HeaderMap, Method},
response::{IntoResponse, Response},
};
use futures_util::future::Either;
@@ -247,7 +247,7 @@ async fn proxy_handler(
&resolved.did,
method,
) {
return e;
return e.into_response();
}
let key_bytes = match auth_user.key_bytes {
@@ -335,7 +335,7 @@ async fn proxy_handler(
Ok(b) => b,
Err(e) => {
error!("Error reading proxy response body: {:?}", e);
return (StatusCode::BAD_GATEWAY, "Error reading upstream response")
return ApiError::UpstreamUnavailable("Error reading upstream response".into())
.into_response();
}
};
@@ -350,16 +350,16 @@ async fn proxy_handler(
Ok(r) => r,
Err(e) => {
error!("Error building proxy response: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
ApiError::InternalError(None).into_response()
}
}
}
Err(e) => {
error!("Error sending proxy request: {:?}", e);
if e.is_timeout() {
(StatusCode::GATEWAY_TIMEOUT, "Upstream Timeout").into_response()
ApiError::UpstreamTimeout.into_response()
} else {
(StatusCode::BAD_GATEWAY, "Upstream Error").into_response()
ApiError::UpstreamFailure.into_response()
}
}
}
+57
View File
@@ -116,3 +116,60 @@ impl<T: Serialize> OptionsResponse<T> {
Json(Self { options })
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountsOutput<T: Serialize> {
pub accounts: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuditLogOutput<T: Serialize> {
pub entries: T,
pub total: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllersOutput<T: Serialize> {
pub controllers: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PresetsOutput<T: Serialize> {
pub presets: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailUpdateStatusOutput {
pub pending: bool,
pub authorized: bool,
pub new_email: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InUseOutput {
pub in_use: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordResetOutput {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub multiple_accounts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreferredLocaleOutput {
pub preferred_locale: Option<String>,
}
@@ -1,5 +1,3 @@
use axum::response::{IntoResponse, Response};
use super::AuthenticatedUser;
use crate::api::error::ApiError;
use crate::state::AppState;
@@ -22,7 +20,7 @@ impl<'a> AccountVerified<'a> {
pub async fn require_verified_or_delegated<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<AccountVerified<'a>, Response> {
) -> Result<AccountVerified<'a>, ApiError> {
let is_verified = state
.user_repo
.has_verified_comms_channel(&user.did)
@@ -43,19 +41,18 @@ pub async fn require_verified_or_delegated<'a>(
return Ok(AccountVerified { user });
}
Err(ApiError::AccountNotVerified.into_response())
Err(ApiError::AccountNotVerified)
}
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> {
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), ApiError> {
match state.user_repo.is_account_migrated(did).await {
Ok(true) => Err(ApiError::AccountMigrated.into_response()),
Ok(true) => Err(ApiError::AccountMigrated),
Ok(false) => Ok(()),
Err(e) => {
tracing::error!("Failed to check migration status: {:?}", e);
Err(
ApiError::InternalError(Some("Failed to verify migration status".into()))
.into_response(),
)
Err(ApiError::InternalError(Some(
"Failed to verify migration status".into(),
)))
}
}
}
+22 -4
View File
@@ -12,7 +12,7 @@ use super::{
is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth,
};
use crate::api::error::ApiError;
use crate::oauth::scopes::{RepoAction, ScopePermissions};
use crate::oauth::scopes::{AccountAction, AccountAttr, RepoAction, ScopePermissions};
use crate::state::AppState;
use crate::types::Did;
use crate::util::build_full_url;
@@ -130,6 +130,12 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
None
}
pub fn extract_jti_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?;
let token = extract_bearer_token_from_header(Some(auth_header))?;
tranquil_auth::get_jti_from_token(&token).ok()
}
pub trait AuthPolicy: Send + Sync + 'static {
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>;
}
@@ -356,14 +362,26 @@ impl<P: AuthPolicy> Auth<P> {
self.0.permissions()
}
#[allow(clippy::result_large_err)]
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> {
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), ApiError> {
if !self.needs_scope_check() {
return Ok(());
}
self.permissions()
.assert_repo(action, collection)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
pub fn check_account_scope(
&self,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), ApiError> {
if !self.needs_scope_check() {
return Ok(());
}
self.permissions()
.assert_account(attr, action)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
}
+43 -11
View File
@@ -1,4 +1,4 @@
use axum::response::Response;
use crate::api::error::ApiError;
use super::AuthenticatedUser;
use crate::state::AppState;
@@ -73,21 +73,29 @@ impl<'a> MfaVerified<'a> {
pub async fn require_legacy_session_mfa<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<MfaVerified<'a>, Response> {
use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
) -> Result<MfaVerified<'a>, ApiError> {
use crate::auth::reauth::check_legacy_session_mfa;
if check_legacy_session_mfa(&*state.session_repo, &user.did).await {
Ok(MfaVerified::from_session_reauth(user))
} else {
Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
let methods = crate::auth::reauth::get_available_reauth_methods(
&*state.user_repo,
&*state.session_repo,
&user.did,
)
.await;
Err(ApiError::MfaVerificationRequiredWithMethods {
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
})
}
}
pub async fn require_reauth_window<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<MfaVerified<'a>, Response> {
use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
) -> Result<MfaVerified<'a>, ApiError> {
use crate::auth::reauth::REAUTH_WINDOW_SECONDS;
use chrono::Utc;
let status = state
@@ -105,10 +113,26 @@ pub async fn require_reauth_window<'a>(
return Ok(MfaVerified::from_session_reauth(user));
}
}
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
let methods = crate::auth::reauth::get_available_reauth_methods(
&*state.user_repo,
&*state.session_repo,
&user.did,
)
.await;
Err(ApiError::ReauthRequired {
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
})
}
None => {
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
let methods = crate::auth::reauth::get_available_reauth_methods(
&*state.user_repo,
&*state.session_repo,
&user.did,
)
.await;
Err(ApiError::ReauthRequired {
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
})
}
}
}
@@ -116,8 +140,8 @@ pub async fn require_reauth_window<'a>(
pub async fn require_reauth_window_if_available<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<Option<MfaVerified<'a>>, Response> {
use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response};
) -> Result<Option<MfaVerified<'a>>, ApiError> {
use crate::auth::reauth::check_reauth_required_cached;
let has_password = state
.user_repo
@@ -144,7 +168,15 @@ pub async fn require_reauth_window_if_available<'a>(
}
if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await {
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
let methods = crate::auth::reauth::get_available_reauth_methods(
&*state.user_repo,
&*state.session_repo,
&user.did,
)
.await;
Err(ApiError::ReauthRequired {
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
})
} else {
Ok(Some(MfaVerified::from_session_reauth(user)))
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub use account_verified::{AccountVerified, require_not_migrated, require_verifi
pub use extractor::{
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken,
NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header,
extract_bearer_token_from_header,
extract_bearer_token_from_header, extract_jti_from_headers,
};
pub use mfa_verified::{
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
+11 -1
View File
@@ -17,6 +17,16 @@ pub enum ReauthMethod {
Passkey,
}
impl ReauthMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Totp => "totp",
Self::Passkey => "passkey",
}
}
}
fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool {
match last_reauth_at {
None => true,
@@ -27,7 +37,7 @@ fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool {
}
}
async fn get_available_reauth_methods(
pub async fn get_available_reauth_methods(
user_repo: &dyn UserRepository,
_session_repo: &dyn SessionRepository,
did: &crate::types::Did,
+10 -14
View File
@@ -1,7 +1,3 @@
#![allow(clippy::result_large_err)]
use axum::response::{IntoResponse, Response};
use crate::api::error::ApiError;
use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
@@ -24,7 +20,7 @@ pub fn check_repo_scope(
scope: Option<&str>,
action: RepoAction,
collection: &str,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -32,14 +28,14 @@ pub fn check_repo_scope(
let permissions = ScopePermissions::from_scope_string(scope);
permissions
.assert_repo(action, collection)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
pub fn check_blob_scope(
auth_source: &AuthSource,
scope: Option<&str>,
mime: &str,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -47,7 +43,7 @@ pub fn check_blob_scope(
let permissions = ScopePermissions::from_scope_string(scope);
permissions
.assert_blob(mime)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
pub fn check_rpc_scope(
@@ -55,7 +51,7 @@ pub fn check_rpc_scope(
scope: Option<&str>,
aud: &str,
lxm: &str,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -63,7 +59,7 @@ pub fn check_rpc_scope(
let permissions = ScopePermissions::from_scope_string(scope);
permissions
.assert_rpc(aud, lxm)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
pub fn check_account_scope(
@@ -71,7 +67,7 @@ pub fn check_account_scope(
scope: Option<&str>,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -79,14 +75,14 @@ pub fn check_account_scope(
let permissions = ScopePermissions::from_scope_string(scope);
permissions
.assert_account(attr, action)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
pub fn check_identity_scope(
auth_source: &AuthSource,
scope: Option<&str>,
attr: IdentityAttr,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -94,5 +90,5 @@ pub fn check_identity_scope(
let permissions = ScopePermissions::from_scope_string(scope);
permissions
.assert_identity(attr)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
}
@@ -80,13 +80,8 @@ pub fn generate_signup_token(did: &Did, channel: CommsChannel, identifier: &str)
generate_token(did, VerificationPurpose::Signup, channel, identifier)
}
pub fn generate_migration_token(did: &Did, email: &str) -> String {
generate_token(
did,
VerificationPurpose::Migration,
CommsChannel::Email,
email,
)
pub fn generate_migration_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
generate_token(did, VerificationPurpose::Migration, channel, identifier)
}
pub fn generate_channel_update_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
@@ -196,16 +191,17 @@ pub fn verify_signup_token(
pub fn verify_migration_token(
token: &str,
expected_email: &str,
expected_channel: CommsChannel,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.purpose != VerificationPurpose::Migration {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != CommsChannel::Email {
if parsed.channel != expected_channel {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_email);
let expected_hash = hash_identifier(expected_identifier);
if parsed.identifier_hash != expected_hash {
return Err(VerifyError::IdentifierMismatch);
}
@@ -345,8 +341,8 @@ mod tests {
init();
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let token = generate_migration_token(&did, email);
let result = verify_migration_token(&token, email);
let token = generate_migration_token(&did, CommsChannel::Email, email);
let result = verify_migration_token(&token, CommsChannel::Email, email);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
assert_eq!(parsed.did, did);
@@ -409,7 +405,7 @@ mod tests {
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
let result = verify_migration_token(&signup_token, email);
let result = verify_migration_token(&signup_token, CommsChannel::Email, email);
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
}
+6 -5
View File
@@ -499,7 +499,8 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
email: &str,
channel: tranquil_db_traits::CommsChannel,
recipient: &str,
token: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
@@ -508,12 +509,12 @@ pub mod repo {
.await?
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let encoded_email = urlencoding::encode(email);
let encoded_recipient = urlencoding::encode(recipient);
let encoded_token = urlencoding::encode(token);
let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
hostname, encoded_token, encoded_recipient
);
let body = format_message(
strings.migration_verification_body,
@@ -531,9 +532,9 @@ pub mod repo {
infra_repo
.enqueue_comms(
Some(user_id),
tranquil_db_traits::CommsChannel::Email,
channel,
CommsType::MigrationVerification,
email,
recipient,
Some(&subject),
&body,
None,
+30 -7
View File
@@ -100,7 +100,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
.layer(DefaultBodyLimit::max(
tranquil_config::get().server.max_blob_size as usize,
))
.layer(axum::middleware::map_response(rewrite_422_to_400))
.layer(axum::middleware::map_response(rewrite_extractor_errors))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
CorsLayer::new()
@@ -150,8 +150,24 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
router
}
async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
if response.status() != StatusCode::UNPROCESSABLE_ENTITY {
fn is_plain_text(headers: &http::HeaderMap) -> bool {
headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.starts_with("text/plain"))
}
fn should_rewrite_to_xrpc_error(response: &axum::response::Response) -> bool {
match response.status() {
StatusCode::UNPROCESSABLE_ENTITY => true,
StatusCode::BAD_REQUEST => is_plain_text(response.headers()),
StatusCode::UNSUPPORTED_MEDIA_TYPE => is_plain_text(response.headers()),
_ => false,
}
}
async fn rewrite_extractor_errors(response: axum::response::Response) -> axum::response::Response {
if !should_rewrite_to_xrpc_error(&response) {
return response;
}
let (mut parts, body) = response.into_parts();
@@ -173,11 +189,11 @@ async fn rewrite_422_to_400(response: axum::response::Response) -> axum::respons
.unwrap_or_else(|| {
String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| "Invalid request body".into())
});
let message = humanize_json_error(&raw);
let message = humanize_extraction_error(&raw);
parts.status = StatusCode::BAD_REQUEST;
parts.headers.remove(http::header::CONTENT_LENGTH);
let error_name = classify_deserialization_error(&raw);
let error_name = classify_extraction_error(&raw);
let new_body = json!({
"error": error_name,
"message": message
@@ -188,7 +204,7 @@ async fn rewrite_422_to_400(response: axum::response::Response) -> axum::respons
)
}
fn humanize_json_error(raw: &str) -> String {
fn humanize_extraction_error(raw: &str) -> String {
if raw.contains("missing field") {
raw.split("missing field `")
.nth(1)
@@ -201,14 +217,21 @@ fn humanize_json_error(raw: &str) -> String {
"Invalid JSON syntax".to_string()
} else if raw.contains("Content-Type") || raw.contains("content type") {
"Content-Type must be application/json".to_string()
} else if raw.contains("Failed to parse") || raw.contains("expected ident") {
"Invalid JSON in request body".to_string()
} else if raw.contains("Failed to deserialize query string") {
raw.strip_prefix("Failed to deserialize query string: ")
.map(|rest| format!("Invalid query parameter: {}", rest))
.unwrap_or_else(|| "Invalid query parameters".into())
} else {
raw.to_string()
}
}
fn classify_deserialization_error(raw: &str) -> &'static str {
fn classify_extraction_error(raw: &str) -> &'static str {
match raw {
s if s.contains("invalid handle") => "InvalidHandle",
s if s.contains("invalid CID") || s.contains("invalid cid") => "InvalidRequest",
_ => "InvalidRequest",
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ async fn test_legacy_2fa_auth_factor_required() {
body["message"]
.as_str()
.unwrap_or("")
.contains("sign in code")
.contains("sign-in code")
);
}
+4 -10
View File
@@ -15,17 +15,15 @@
import OAuthConsent from './routes/OAuthConsent.svelte'
import OAuthLogin from './routes/OAuthLogin.svelte'
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthTotp from './routes/OAuthTotp.svelte'
import OAuthVerifyCode from './routes/OAuthVerifyCode.svelte'
import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthDelegation from './routes/OAuthDelegation.svelte'
import OAuthError from './routes/OAuthError.svelte'
import SsoRegisterComplete from './routes/SsoRegisterComplete.svelte'
import Register from './routes/Register.svelte'
import RegisterPassword from './routes/RegisterPassword.svelte'
import ActAs from './routes/ActAs.svelte'
import Migration from './routes/Migration.svelte'
import UiTest from './routes/UiTest.svelte'
import { _ } from './lib/i18n'
initI18n()
@@ -105,9 +103,8 @@
case '/oauth/accounts':
return OAuthAccounts
case '/oauth/2fa':
return OAuth2FA
case '/oauth/totp':
return OAuthTotp
return OAuthVerifyCode
case '/oauth/passkey':
return OAuthPasskey
case '/oauth/delegation':
@@ -118,17 +115,14 @@
return SsoRegisterComplete
case '/register':
case '/oauth/register':
case '/oauth/register-password':
return Register
case '/oauth/register-sso':
return RegisterSso
case '/oauth/register-password':
return RegisterPassword
case '/act-as':
return ActAs
case '/migrate':
return Migration
case '/ui-test':
return UiTest
default:
return Login
}
@@ -0,0 +1,119 @@
<script lang="ts">
import type { VerificationChannel } from '../lib/types/api'
import { _ } from '../lib/i18n'
interface Props {
channel: VerificationChannel
email: string
discordUsername: string
telegramUsername: string
signalUsername: string
availableChannels: VerificationChannel[]
disabled?: boolean
onChannelChange: (channel: VerificationChannel) => void
onEmailChange: (value: string) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
}
let {
channel,
email,
discordUsername,
telegramUsername,
signalUsername,
availableChannels,
disabled = false,
onChannelChange,
onEmailChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
}: Props = $props()
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
}
}
function isAvailable(ch: VerificationChannel): boolean {
return availableChannels.includes(ch)
}
</script>
<div>
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" value={channel} onchange={(e) => onChannelChange((e.target as HTMLSelectElement).value as VerificationChannel)} {disabled}>
<option value="email">{channelLabel('email')}</option>
{#if isAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if channel === 'email'}
<div>
<label for="comms-email">{$_('register.emailAddress')}</label>
<input
id="comms-email"
type="email"
value={email}
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
placeholder={$_('register.emailPlaceholder')}
{disabled}
required
/>
</div>
{:else if channel === 'discord'}
<div>
<label for="comms-discord">{$_('register.discordUsername')}</label>
<input
id="comms-discord"
type="text"
value={discordUsername}
oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)}
placeholder={$_('register.discordUsernamePlaceholder')}
{disabled}
required
/>
</div>
{:else if channel === 'telegram'}
<div>
<label for="comms-telegram">{$_('register.telegramUsername')}</label>
<input
id="comms-telegram"
type="text"
value={telegramUsername}
oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)}
placeholder={$_('register.telegramUsernamePlaceholder')}
{disabled}
required
/>
</div>
{:else if channel === 'signal'}
<div>
<label for="comms-signal">{$_('register.signalUsername')}</label>
<input
id="comms-signal"
type="tel"
value={signalUsername}
oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)}
placeholder={$_('register.signalUsernamePlaceholder')}
{disabled}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
@@ -7,6 +7,9 @@
placeholder?: string
id?: string
autocomplete?: HTMLInputElement['autocomplete']
checkAvailability?: (fullHandle: string) => Promise<boolean>
available?: boolean | null
checking?: boolean
onInput: (value: string) => void
onDomainChange: (domain: string) => void
}
@@ -19,11 +22,42 @@
placeholder = 'username',
id = 'handle',
autocomplete = 'off',
checkAvailability,
available = $bindable<boolean | null>(null),
checking = $bindable(false),
onInput,
onDomainChange,
}: Props = $props()
const showDomainSelect = $derived(domains.length > 1 && !value.includes('.'))
let checkTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void value
void selectedDomain
if (!checkAvailability) return
if (checkTimeout) clearTimeout(checkTimeout)
available = null
if (value.trim().length >= 3 && !value.includes('.')) {
checkTimeout = setTimeout(() => runCheck(), 400)
}
})
async function runCheck() {
if (!checkAvailability) return
const fullHandle = value.includes('.')
? value.trim()
: `${value.trim()}.${selectedDomain}`
checking = true
try {
available = await checkAvailability(fullHandle)
} catch {
available = null
} finally {
checking = false
}
}
</script>
<div class="handle-input-group">
@@ -0,0 +1,78 @@
<script lang="ts">
import { _ } from '../lib/i18n'
interface Props {
didType: 'plc' | 'web' | 'web-external'
externalDid: string
disabled: boolean
selfHostedDidWebEnabled: boolean
defaultDomain: string
onDidTypeChange: (value: 'plc' | 'web' | 'web-external') => void
onExternalDidChange: (value: string) => void
}
let {
didType,
externalDid,
disabled,
selfHostedDidWebEnabled,
defaultDomain,
onDidTypeChange,
onExternalDidChange,
}: Props = $props()
function extractDomain(did: string): string {
return did.replace(/^did:web:/, '').split(':')[0] || 'yourdomain.com'
}
</script>
<fieldset class="identity-section">
<legend>{$_('registerPasskey.identityType')}</legend>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" checked={didType === 'plc'} onchange={() => onDidTypeChange('plc')} {disabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={!selfHostedDidWebEnabled}>
<input type="radio" name="didType" value="web" checked={didType === 'web'} onchange={() => onDidTypeChange('web')} disabled={disabled || !selfHostedDidWebEnabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if !selfHostedDidWebEnabled}
<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" checked={didType === 'web-external'} onchange={() => onDidTypeChange('web-external')} {disabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
</fieldset>
{#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.${defaultDomain}</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>
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" value={externalDid} oninput={(e) => onExternalDidChange(e.currentTarget.value)} placeholder={$_('registerPasskey.externalDidPlaceholder')} {disabled} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
+14 -12
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { portal } from '../lib/portal'
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
@@ -136,7 +137,7 @@
</script>
{#if show}
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
<div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2>{$_('reauth.title')}</h2>
@@ -181,7 +182,7 @@
<div class="modal-content">
{#if activeMethod === 'password'}
<form onsubmit={handlePasswordSubmit}>
<form id="reauth-form" onsubmit={handlePasswordSubmit}>
<div>
<label for="reauth-password">{$_('reauth.password')}</label>
<input
@@ -192,12 +193,9 @@
autocomplete="current-password"
/>
</div>
<button type="submit" disabled={loading || !password}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'totp'}
<form onsubmit={handleTotpSubmit}>
<form id="reauth-form" onsubmit={handleTotpSubmit}>
<div>
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
<input
@@ -211,15 +209,10 @@
maxlength="6"
/>
</div>
<button type="submit" disabled={loading || !totpCode}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'passkey'}
<div class="passkey-auth">
<button onclick={handlePasskeyAuth} disabled={loading}>
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
</button>
<p>{$_('reauth.usePasskey')}</p>
</div>
{/if}
</div>
@@ -228,6 +221,15 @@
<button class="secondary" onclick={handleClose} disabled={loading}>
{$_('reauth.cancel')}
</button>
{#if activeMethod === 'passkey'}
<button onclick={handlePasskeyAuth} disabled={loading}>
{loading ? $_('reauth.authenticating') : $_('common.verify')}
</button>
{:else}
<button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
{/if}
</div>
</div>
</div>
@@ -1,60 +0,0 @@
<script lang="ts">
import { _ } from '../../lib/i18n'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
}
let { session }: Props = $props()
</script>
<div class="overview">
<dl>
<dt>{$_('dashboard.handle')}</dt>
<dd>
@{session.handle}
{#if session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if session.accountKind === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if session.accountKind === 'deactivated'}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
<dt>{$_('dashboard.did')}</dt>
<dd class="mono">{session.did}</dd>
{#if session.contactKind === 'channel'}
<dt>{$_('dashboard.primaryContact')}</dt>
<dd>
{#if session.preferredChannel === 'email'}
{session.email || $_('register.email')}
{:else if session.preferredChannel === 'discord'}
{$_('register.discord')}
{:else if session.preferredChannel === 'telegram'}
{$_('register.telegram')}
{:else if session.preferredChannel === 'signal'}
{$_('register.signal')}
{:else}
{session.preferredChannel}
{/if}
{#if session.preferredChannelVerified}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{:else if session.contactKind === 'email'}
<dt>{$_('register.email')}</dt>
<dd>
{session.email}
{#if session.emailConfirmed}
<span class="badge success">{$_('dashboard.verified')}</span>
{:else}
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{/if}
</dl>
</div>
@@ -11,6 +11,8 @@
setColors as setGlobalColors,
setHasLogo as setGlobalHasLogo
} from '../../lib/serverConfig.svelte'
import LoadMoreSentinel from '../LoadMoreSentinel.svelte'
import { portal } from '../../lib/portal'
interface Props {
session: Session
@@ -32,7 +34,6 @@
indexedAt: string
emailConfirmedAt?: string
deactivatedAt?: string
invitesDisabled?: boolean
}
let stats = $state<ServerStats | null>(null)
@@ -41,6 +42,8 @@
let usersLoading = $state(false)
let searchQuery = $state('')
let usersCursor = $state<string | undefined>(undefined)
let usersHasMore = $state(true)
let searchDebounce: ReturnType<typeof setTimeout> | null = null
let selectedUser = $state<User | null>(null)
let userActionLoading = $state(false)
@@ -63,7 +66,7 @@
let serverConfigLoading = $state(false)
onMount(async () => {
await Promise.all([loadStats(), loadServerConfig()])
await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)])
})
async function loadStats() {
@@ -82,6 +85,7 @@
if (reset) {
users = []
usersCursor = undefined
usersHasMore = true
}
try {
const result = await api.searchAccounts(session.accessJwt, {
@@ -91,6 +95,7 @@
})
users = reset ? result.accounts : [...users, ...result.accounts]
usersCursor = result.cursor
usersHasMore = !!result.cursor
} catch {
toast.error($_('admin.failedToLoadUsers'))
} finally {
@@ -98,9 +103,10 @@
}
}
function handleSearch(e: Event) {
e.preventDefault()
loadUsers(true)
function onSearchInput(value: string) {
searchQuery = value
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadUsers(true), 300)
}
function formatBytes(bytes: number): string {
@@ -216,7 +222,6 @@
indexedAt: details.indexedAt,
emailConfirmedAt: details.emailConfirmedAt,
deactivatedAt: details.deactivatedAt,
invitesDisabled: details.invitesDisabled
}
} catch {
} finally {
@@ -228,26 +233,6 @@
selectedUser = null
}
async function toggleUserInvites() {
if (!selectedUser) return
userActionLoading = true
try {
if (selectedUser.invitesDisabled) {
await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: false }
toast.success($_('admin.invitesEnabled'))
} else {
await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: true }
toast.success($_('admin.invitesDisabled'))
}
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToToggleInvites'))
} finally {
userActionLoading = false
}
}
async function deleteUserAccount() {
if (!selectedUser) return
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
@@ -372,16 +357,12 @@
<section class="users-section">
<h3>{$_('admin.userManagement')}</h3>
<form class="search-bar" onsubmit={handleSearch}>
<input
type="text"
bind:value={searchQuery}
placeholder={$_('admin.searchPlaceholder')}
/>
<button type="submit" disabled={usersLoading}>
{usersLoading ? $_('common.loading') : $_('admin.search')}
</button>
</form>
<input
type="text"
value={searchQuery}
oninput={(e) => onSearchInput(e.currentTarget.value)}
placeholder={$_('admin.searchPlaceholder')}
/>
{#if users.length === 0 && !usersLoading}
<p class="empty">{$_('admin.searchToSeeUsers')}</p>
@@ -412,18 +393,14 @@
</li>
{/each}
</ul>
{#if usersCursor}
<button type="button" class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
{usersLoading ? $_('common.loading') : $_('admin.loadMore')}
</button>
{/if}
<LoadMoreSentinel hasMore={usersHasMore} loading={usersLoading} onLoadMore={() => loadUsers(false)} />
{/if}
</section>
</div>
{#if selectedUser}
<div class="modal-backdrop" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
<div class="modal-backdrop" use:portal onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2>{$_('admin.userDetails')}</h2>
@@ -437,7 +414,7 @@
<dt>{$_('admin.handle')}</dt>
<dd>@{selectedUser.handle}</dd>
<dt>{$_('admin.did')}</dt>
<dd class="mono">{selectedUser.did}</dd>
<dd class="definition-mono">{selectedUser.did}</dd>
<dt>{$_('admin.email')}</dt>
<dd>{selectedUser.email || '-'}</dd>
<dt>{$_('admin.created')}</dt>
@@ -452,22 +429,8 @@
<span class="badge unverified">{$_('admin.unverified')}</span>
{/if}
</dd>
<dt>{$_('admin.invites')}</dt>
<dd>
{#if selectedUser.invitesDisabled}
<span class="badge deactivated">{$_('admin.disabled')}</span>
{:else}
<span class="badge verified">{$_('admin.enabled')}</span>
{/if}
</dd>
</dl>
<div class="modal-actions">
<button
onclick={toggleUserInvites}
disabled={userActionLoading}
>
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
</button>
<button
class="danger"
onclick={deleteUserAccount}
@@ -33,9 +33,6 @@
let verifyingChannel = $state<string | null>(null)
let verificationCode = $state('')
let historyLoading = $state(true)
let discordInUse = $state(false)
let telegramInUse = $state(false)
let signalInUse = $state(false)
let messages = $state<Array<{
createdAt: string
channel: string
@@ -147,23 +144,6 @@
return formatDateTime(dateStr)
}
async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) {
const trimmed = identifier.trim()
if (!trimmed) {
const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false }
resetMap[channel]()
return
}
try {
const result = await api.checkCommsChannelInUse(channel, trimmed)
const setMap = { discord: (v: boolean) => discordInUse = v, telegram: (v: boolean) => telegramInUse = v, signal: (v: boolean) => signalInUse = v }
setMap[channel](result.inUse)
} catch {
const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false }
resetMap[channel]()
}
}
const channels = ['email', 'discord', 'telegram', 'signal']
function getChannelName(id: string): string {
@@ -261,14 +241,10 @@
id="discord"
type="text"
bind:value={discordUsername}
onblur={() => checkChannelInUse('discord', discordUsername)}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if discordInUse}
<p class="hint warning">{$_('comms.discordInUseWarning')}</p>
{/if}
{#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="discord-verify-prompt">
@@ -296,14 +272,10 @@
id="telegram"
type="text"
bind:value={telegramUsername}
onblur={() => checkChannelInUse('telegram', telegramUsername)}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if telegramInUse}
<p class="hint warning">{$_('comms.telegramInUseWarning')}</p>
{/if}
{#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="telegram-verify-prompt">
@@ -329,14 +301,10 @@
id="signal"
type="text"
bind:value={signalUsername}
onblur={() => checkChannelInUse('signal', signalUsername)}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={saving}
/>
</div>
{#if signalInUse}
<p class="hint warning">{$_('comms.signalInUseWarning')}</p>
{/if}
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
@@ -376,12 +376,12 @@
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
<span class="detail-label">{$_('delegation.did')}</span>
<span class="detail-value detail-value-did">{controller.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
<span class="detail-label">{$_('delegation.granted')}</span>
<span class="detail-value">{formatDateTime(controller.grantedAt)}</span>
</div>
</div>
</div>
@@ -507,12 +507,12 @@
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
<span class="detail-label">{$_('delegation.did')}</span>
<span class="detail-value detail-value-did">{account.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
<span class="detail-label">{$_('delegation.granted')}</span>
<span class="detail-value">{formatDateTime(account.grantedAt)}</span>
</div>
</div>
</div>
@@ -601,19 +601,19 @@
</div>
<div class="audit-entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did">{entry.actorDid}</span>
<span class="detail-label">{$_('delegation.actor')}</span>
<span class="detail-value detail-value-did">{entry.actorDid}</span>
</div>
{#if entry.delegatedDid}
<div class="detail">
<span class="label">{$_('delegation.target')}</span>
<span class="value did">{entry.delegatedDid}</span>
<span class="detail-label">{$_('delegation.target')}</span>
<span class="detail-value detail-value-did">{entry.delegatedDid}</span>
</div>
{/if}
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
<span class="detail-label">{$_('delegation.details')}</span>
<span class="detail-value audit-details-value">{formatActionDetails(entry.actionDetails)}</span>
</div>
{/if}
</div>
@@ -1,53 +0,0 @@
<script lang="ts">
import { _ } from '../../lib/i18n'
import { navigate, routes } from '../../lib/router.svelte'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
}
let { session }: Props = $props()
function startMigration(type: 'inbound' | 'offline') {
const url = type === 'offline'
? `${routes.migrate}?flow=offline`
: routes.migrate
navigate(url as typeof routes.migrate)
}
</script>
<div class="migration">
<section>
<h3>{$_('migration.migrateHere')}</h3>
<p class="description">{$_('migration.migrateHereDesc')}</p>
<ul class="feature-list">
<li>{$_('migration.bringDid')}</li>
<li>{$_('migration.transferData')}</li>
<li>{$_('migration.keepFollowers')}</li>
</ul>
<button onclick={() => startMigration('inbound')}>
{$_('migration.inbound.review.startMigration')}
</button>
</section>
<section>
<h3>{$_('migration.offlineRestore')}</h3>
<p class="description">{$_('migration.offlineRestoreDesc')}</p>
<ul class="feature-list">
<li>{$_('migration.offlineFeature1')}</li>
<li>{$_('migration.offlineFeature2')}</li>
<li>{$_('migration.offlineFeature3')}</li>
</ul>
<button class="secondary" onclick={() => startMigration('offline')}>
{$_('migration.offlineRestore')}
</button>
</section>
{#if session.accountKind === 'migrated'}
<section class="info-section">
<h3>{$_('dashboard.migratedTitle')}</h3>
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
</section>
{/if}
</div>
@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { formatDate } from '../../lib/date'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../../lib/webauthn'
interface Props {
session: Session
hasPassword: boolean
onPasskeysChanged?: (count: number) => void
}
let { session, hasPassword, onPasskeysChanged }: Props = $props()
interface Passkey {
id: string
credentialId: string
friendlyName: string | null
createdAt: string
lastUsed: string | null
}
let passkeys = $state<Passkey[]>([])
let loading = $state(true)
let addingPasskey = $state(false)
let newPasskeyName = $state('')
let editingPasskeyId = $state<string | null>(null)
let editPasskeyName = $state('')
onMount(async () => {
await loadPasskeys()
})
async function loadPasskeys() {
loading = true
try {
const result = await api.listPasskeys(session.accessJwt)
passkeys = result.passkeys
onPasskeysChanged?.(passkeys.length)
} catch {
toast.error($_('security.failedToLoadPasskeys'))
} finally {
loading = false
}
}
async function handleAddPasskey() {
if (!window.PublicKeyCredential) {
toast.error($_('security.passkeysNotSupported'))
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions })
if (!credential) {
toast.error($_('security.passkeyCreationCancelled'))
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
toast.success($_('security.passkeyAddedSuccess'))
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
toast.error($_('security.passkeyCreationCancelled'))
} else {
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
}
} finally {
addingPasskey = false
}
}
async function handleDeletePasskey(id: string) {
const passkey = passkeys.find(p => p.id === id)
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
try {
await api.deletePasskey(session.accessJwt, id)
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
}
}
async function handleSavePasskeyName() {
if (!editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
toast.success($_('security.passkeyRenamed'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
}
}
function startEditPasskey(passkey: Passkey) {
editingPasskeyId = passkey.id
editPasskeyName = passkey.friendlyName || ''
}
function cancelEditPasskey() {
editingPasskeyId = null
editPasskeyName = ''
}
</script>
<section>
<h3>{$_('security.passkeys')}</h3>
{#if !loading}
{#if passkeys.length > 0}
<ul class="passkey-list">
{#each passkeys as passkey}
<li class="passkey-item">
{#if editingPasskeyId === passkey.id}
<div class="passkey-edit">
<input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} />
<button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button>
<button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button>
</div>
{:else}
<div class="passkey-info">
<span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span>
<span class="passkey-meta">
{$_('security.added')} {formatDate(passkey.createdAt)}
{#if passkey.lastUsed}
- {$_('security.lastUsed')} {formatDate(passkey.lastUsed)}
{/if}
</span>
</div>
<div class="passkey-actions">
<button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button>
{#if hasPassword || passkeys.length > 1}
<button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{:else}
<div class="status warning">{$_('security.noPasskeys')}</div>
{/if}
<div class="add-passkey">
<input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} />
<button onclick={handleAddPasskey} disabled={addingPasskey}>
{addingPasskey ? $_('security.adding') : $_('security.addPasskey')}
</button>
</div>
{/if}
</section>
@@ -0,0 +1,265 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getValidToken } from '../../lib/auth.svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
interface Props {
session: Session
passkeyCount: number
onPasswordChanged?: (hasPassword: boolean) => void
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
}
let { session, passkeyCount, onPasswordChanged, onReauthRequired }: Props = $props()
let hasPassword = $state(true)
let loading = $state(true)
let showChangePasswordForm = $state(false)
let currentPassword = $state('')
let newPassword = $state('')
let confirmNewPassword = $state('')
let changePasswordLoading = $state(false)
let showSetPasswordForm = $state(false)
let setNewPassword = $state('')
let setConfirmPassword = $state('')
let setPasswordLoading = $state(false)
let showRemovePasswordForm = $state(false)
let removePasswordLoading = $state(false)
onMount(async () => {
await loadPasswordStatus()
})
async function loadPasswordStatus() {
loading = true
try {
const status = await api.getPasswordStatus(session.accessJwt)
hasPassword = status.hasPassword
onPasswordChanged?.(hasPassword)
} catch {
hasPassword = true
} finally {
loading = false
}
}
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired') {
onReauthRequired(e.reauthMethods || ['password'], retryAction)
} else {
toast.error(e.message)
}
} else {
toast.error(fallback)
}
}
async function handleChangePassword(e: Event) {
e.preventDefault()
if (!currentPassword || !newPassword || !confirmNewPassword) return
if (newPassword !== confirmNewPassword) {
toast.error($_('security.passwordsDoNotMatch'))
return
}
if (newPassword.length < 8) {
toast.error($_('security.passwordTooShort'))
return
}
changePasswordLoading = true
try {
await api.changePassword(session.accessJwt, currentPassword, newPassword)
toast.success($_('security.passwordChanged'))
currentPassword = ''
newPassword = ''
confirmNewPassword = ''
showChangePasswordForm = false
} catch (e) {
handleReauthError(e, $_('security.failedToChangePassword'), () => handleChangePassword(new Event('submit')))
} finally {
changePasswordLoading = false
}
}
async function handleSetPassword(e: Event) {
e.preventDefault()
if (!setNewPassword || !setConfirmPassword) return
if (setNewPassword !== setConfirmPassword) {
toast.error($_('security.passwordsDoNotMatch'))
return
}
if (setNewPassword.length < 8) {
toast.error($_('security.passwordTooShort'))
return
}
setPasswordLoading = true
try {
await api.setPassword(session.accessJwt, setNewPassword)
hasPassword = true
toast.success($_('security.passwordSet'))
setNewPassword = ''
setConfirmPassword = ''
showSetPasswordForm = false
onPasswordChanged?.(true)
} catch (e) {
handleReauthError(e, $_('security.failedToSetPassword'), () => handleSetPassword(new Event('submit')))
} finally {
setPasswordLoading = false
}
}
async function handleRemovePassword() {
removePasswordLoading = true
try {
const token = await getValidToken()
if (!token) {
toast.error($_('security.sessionExpired'))
return
}
await api.removePassword(token)
hasPassword = false
showRemovePasswordForm = false
toast.success($_('security.passwordRemoved'))
onPasswordChanged?.(false)
} catch (e) {
handleReauthError(e, $_('security.failedToRemovePassword'), handleRemovePassword)
} finally {
removePasswordLoading = false
}
}
</script>
<section>
<h3>{$_('security.password')}</h3>
{#if !loading}
{#if hasPassword}
<div class="status success">{$_('security.passwordStatus')}</div>
{#if !showChangePasswordForm && !showRemovePasswordForm}
<div class="password-actions">
<button type="button" onclick={() => showChangePasswordForm = true}>
{$_('security.changePassword')}
</button>
{#if passkeyCount > 0}
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
{$_('security.removePassword')}
</button>
{/if}
</div>
{/if}
{#if showChangePasswordForm}
<form class="inline-form" onsubmit={handleChangePassword}>
<h4>{$_('security.changePassword')}</h4>
<div>
<label for="current-password">{$_('security.currentPassword')}</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
placeholder={$_('security.currentPasswordPlaceholder')}
disabled={changePasswordLoading}
required
/>
</div>
<div>
<label for="new-password">{$_('security.newPassword')}</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder={$_('security.newPasswordPlaceholder')}
disabled={changePasswordLoading}
required
minlength="8"
/>
</div>
<div>
<label for="confirm-password">{$_('security.confirmPassword')}</label>
<input
id="confirm-password"
type="password"
bind:value={confirmNewPassword}
placeholder={$_('security.confirmPasswordPlaceholder')}
disabled={changePasswordLoading}
required
minlength="8"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
{changePasswordLoading ? $_('security.changing') : $_('security.changePassword')}
</button>
</div>
</form>
{/if}
{#if showRemovePasswordForm}
<div class="remove-password-form">
<p class="warning-text">{$_('security.removePasswordWarning')}</p>
<div class="actions">
<button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}>
{$_('common.cancel')}
</button>
<button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}>
{removePasswordLoading ? $_('security.removing') : $_('security.removePassword')}
</button>
</div>
</div>
{/if}
{:else}
<div class="status info">{$_('security.noPassword')}</div>
{#if !showSetPasswordForm}
<button type="button" onclick={() => showSetPasswordForm = true}>
{$_('security.setPassword')}
</button>
{:else}
<form class="inline-form" onsubmit={handleSetPassword}>
<h4>{$_('security.setPassword')}</h4>
<div>
<label for="set-new-password">{$_('security.newPassword')}</label>
<input
id="set-new-password"
type="password"
bind:value={setNewPassword}
placeholder={$_('security.newPasswordPlaceholder')}
disabled={setPasswordLoading}
required
minlength="8"
/>
</div>
<div>
<label for="set-confirm-password">{$_('security.confirmPassword')}</label>
<input
id="set-confirm-password"
type="password"
bind:value={setConfirmPassword}
placeholder={$_('security.confirmPasswordPlaceholder')}
disabled={setPasswordLoading}
required
minlength="8"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}>
{setPasswordLoading ? $_('security.setting') : $_('security.setPassword')}
</button>
</div>
</form>
{/if}
{/if}
{/if}
</section>
File diff suppressed because it is too large Load Diff
@@ -116,12 +116,12 @@
</div>
<div class="session-details">
<div class="detail">
<span class="label">{$_('sessions.created')}</span>
<span class="value">{timeAgo(s.createdAt)}</span>
<span class="detail-label">{$_('sessions.created')}</span>
<span class="detail-value">{timeAgo(s.createdAt)}</span>
</div>
<div class="detail">
<span class="label">{$_('sessions.expires')}</span>
<span class="value">{formatDate(s.expiresAt)}</span>
<span class="detail-label">{$_('sessions.expires')}</span>
<span class="detail-value">{formatDate(s.expiresAt)}</span>
</div>
</div>
</div>
@@ -0,0 +1,279 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError } from '../../lib/api'
import { _ } from '../../lib/i18n'
import { toast } from '../../lib/toast.svelte'
import type { Session } from '../../lib/types/api'
import {
type TotpSetupState,
idleState,
qrState,
verifyState,
backupState,
goBackToQr,
finish,
type TotpQr,
} from '../../lib/types/totp-state'
interface Props {
session: Session
onStatusChanged?: (enabled: boolean, hasBackupCodes: boolean) => void
}
let { session, onStatusChanged }: Props = $props()
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
let totpSetup = $state<TotpSetupState>(idleState)
let verifyCodeRaw = $state('')
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
let verifyLoading = $state(false)
let disablePassword = $state('')
let disableCode = $state('')
let disableLoading = $state(false)
let showDisableForm = $state(false)
let regenPassword = $state('')
let regenCode = $state('')
let regenLoading = $state(false)
let showRegenForm = $state(false)
onMount(async () => {
await loadTotpStatus()
})
async function loadTotpStatus() {
try {
const status = await api.getTotpStatus(session.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
onStatusChanged?.(totpEnabled, hasBackupCodes)
} catch {
toast.error($_('security.failedToLoadTotpStatus'))
}
}
async function handleStartTotpSetup() {
verifyLoading = true
try {
const result = await api.createTotpSecret(session.accessJwt)
totpSetup = qrState(result.qrBase64, result.uri)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
verifyLoading = false
}
}
async function handleVerifyTotp(e: Event) {
e.preventDefault()
if (!verifyCode || totpSetup.step !== 'verify') return
verifyLoading = true
try {
const result = await api.enableTotp(session.accessJwt, verifyCode)
totpSetup = backupState(totpSetup, result.backupCodes)
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
onStatusChanged?.(true, true)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Invalid code')
} finally {
verifyLoading = false
}
}
function handleFinishSetup() {
if (totpSetup.step !== 'backup') return
totpSetup = finish(totpSetup)
toast.success($_('security.totpEnabledSuccess'))
}
function copyBackupCodes() {
if (totpSetup.step !== 'backup') return
navigator.clipboard.writeText(totpSetup.backupCodes.join('\n'))
toast.success($_('security.backupCodesCopied'))
}
async function handleDisableTotp(e: Event) {
e.preventDefault()
if (!disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
disablePassword = ''
disableCode = ''
toast.success($_('security.totpDisabledSuccess'))
onStatusChanged?.(false, false)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp'))
} finally {
disableLoading = false
}
}
async function handleRegenerateBackupCodes(e: Event) {
e.preventDefault()
if (!regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
const dummyVerify = verifyState(qrState('', ''))
totpSetup = backupState(dummyVerify, result.backupCodes)
showRegenForm = false
regenPassword = ''
regenCode = ''
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes'))
} finally {
regenLoading = false
}
}
</script>
<section>
<h3>{$_('security.totp')}</h3>
{#if totpSetup.step === 'idle'}
{#if totpEnabled}
<div class="status success">{$_('security.totpEnabled')}</div>
{#if !showDisableForm && !showRegenForm}
<div class="totp-actions">
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
{$_('security.regenerateBackupCodes')}
</button>
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
{$_('security.disableTotp')}
</button>
</div>
{/if}
{#if showRegenForm}
<form class="inline-form" onsubmit={handleRegenerateBackupCodes}>
<h4>{$_('security.regenerateBackupCodes')}</h4>
<p class="warning-text">{$_('security.regenerateConfirm')}</p>
<div>
<label for="regen-password">{$_('security.password')}</label>
<input
id="regen-password"
type="password"
bind:value={regenPassword}
placeholder={$_('security.enterPassword')}
disabled={regenLoading}
required
/>
</div>
<div>
<label for="regen-code">{$_('security.totpCode')}</label>
<input
id="regen-code"
type="text"
bind:value={regenCode}
placeholder={$_('security.totpCodePlaceholder')}
disabled={regenLoading}
required
maxlength="6"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
{regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')}
</button>
</div>
</form>
{/if}
{#if showDisableForm}
<form class="inline-form danger-form" onsubmit={handleDisableTotp}>
<h4>{$_('security.disableTotp')}</h4>
<p class="warning-text">{$_('security.disableTotpWarning')}</p>
<div>
<label for="disable-password">{$_('security.password')}</label>
<input
id="disable-password"
type="password"
bind:value={disablePassword}
placeholder={$_('security.enterPassword')}
disabled={disableLoading}
required
/>
</div>
<div>
<label for="disable-code">{$_('security.totpCode')}</label>
<input
id="disable-code"
type="text"
bind:value={disableCode}
placeholder={$_('security.totpCodePlaceholder')}
disabled={disableLoading}
required
maxlength="6"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
{$_('common.cancel')}
</button>
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
{disableLoading ? $_('security.disabling') : $_('security.disableTotp')}
</button>
</div>
</form>
{/if}
{:else}
<div class="status warning">{$_('security.totpDisabled')}</div>
<button onclick={handleStartTotpSetup} disabled={verifyLoading}>
{$_('security.enableTotp')}
</button>
{/if}
{:else if totpSetup.step === 'qr'}
{@const qrData = totpSetup as TotpQr}
<div class="setup-step">
<p>{$_('security.totpSetupInstructions')}</p>
<div class="qr-container">
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
</div>
<details class="manual-entry">
<summary>{$_('security.cantScan')}</summary>
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
</details>
<button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button>
</div>
{:else if totpSetup.step === 'verify'}
{@const verifyData = totpSetup}
<div class="setup-step">
<p>{$_('security.totpCodePlaceholder')}</p>
<form onsubmit={handleVerifyTotp}>
<input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} />
<div class="actions">
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button>
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button>
</div>
</form>
</div>
{:else if totpSetup.step === 'backup'}
<div class="setup-step">
<h4>{$_('security.backupCodes')}</h4>
<p class="warning-text">{$_('security.backupCodesDescription')}</p>
<div class="backup-codes">
{#each totpSetup.backupCodes as code}
<code class="backup-code">{code}</code>
{/each}
</div>
<div class="actions">
<button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button>
<button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button>
</div>
</div>
{/if}
</section>
@@ -1,34 +1,44 @@
<script lang="ts">
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import type { AuthMethod, HandlePreservation, ServerDescription, VerificationChannel } from '../../lib/migration/types'
import type { VerificationChannel as ApiVerificationChannel } from '../../lib/types/api'
import { _ } from '../../lib/i18n'
import HandleInput from '../HandleInput.svelte'
import CommsChannelPicker from '../CommsChannelPicker.svelte'
interface Props {
handleInput: string
selectedDomain: string
handleAvailable: boolean | null
checkingHandle: boolean
email: string
password: string
authMethod: AuthMethod
inviteCode: string
serverInfo: ServerDescription | null
availableCommsChannels: ApiVerificationChannel[]
verificationChannel: VerificationChannel
discordUsername: string
telegramUsername: string
signalUsername: string
migratingFromLabel: string
migratingFromValue: string
loading?: boolean
sourceHandle: string
sourceDid: string
sourcePdsDomains?: string[]
handlePreservation: HandlePreservation
existingHandleVerified: boolean
verifyingExistingHandle?: boolean
existingHandleError?: string | null
checkAvailability: (fullHandle: string) => Promise<boolean>
onHandleChange: (handle: string) => void
onDomainChange: (domain: string) => void
onCheckHandle: () => void
onEmailChange: (email: string) => void
onPasswordChange: (password: string) => void
onAuthMethodChange: (method: AuthMethod) => void
onInviteCodeChange: (code: string) => void
onVerificationChannelChange: (channel: VerificationChannel) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
onHandlePreservationChange?: (preservation: HandlePreservation) => void
onVerifyExistingHandle?: () => void
onBack: () => void
@@ -38,45 +48,70 @@
let {
handleInput,
selectedDomain,
handleAvailable,
checkingHandle,
email,
password,
authMethod,
inviteCode,
serverInfo,
availableCommsChannels,
verificationChannel,
discordUsername,
telegramUsername,
signalUsername,
migratingFromLabel,
migratingFromValue,
loading = false,
sourceHandle,
sourceDid,
sourcePdsDomains = [],
handlePreservation,
existingHandleVerified,
verifyingExistingHandle = false,
existingHandleError = null,
checkAvailability,
onHandleChange,
onDomainChange,
onCheckHandle,
onEmailChange,
onPasswordChange,
onAuthMethodChange,
onInviteCodeChange,
onVerificationChannelChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
onHandlePreservationChange,
onVerifyExistingHandle,
onBack,
onContinue,
}: Props = $props()
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
const isExternalHandle = $derived(
serverInfo != null &&
const isSourcePdsManaged = $derived(
sourcePdsDomains.length > 0 &&
sourceHandle.includes('.') &&
sourcePdsDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const isExternalHandle = $derived(
sourceHandle.includes('.') &&
!isSourcePdsManaged &&
serverInfo != null &&
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const hasVerificationIdentifier = $derived(
(verificationChannel === 'email' && email.trim().length > 0) ||
(verificationChannel === 'discord' && discordUsername.trim().length > 0) ||
(verificationChannel === 'telegram' && telegramUsername.trim().length > 0) ||
(verificationChannel === 'signal' && signalUsername.trim().length > 0)
)
const canContinue = $derived(
email &&
hasVerificationIdentifier &&
(authMethod === 'passkey' || password) &&
(
(handlePreservation === 'existing' && existingHandleVerified) ||
@@ -178,6 +213,9 @@
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder="username"
{checkAvailability}
bind:available={handleAvailable}
bind:checking={checkingHandle}
onInput={onHandleChange}
onDomainChange={onDomainChange}
/>
@@ -196,17 +234,20 @@
</div>
{/if}
<div class="field">
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
<input
id="email"
type="email"
placeholder="you@example.com"
value={email}
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
required
/>
</div>
<CommsChannelPicker
channel={verificationChannel}
{email}
{discordUsername}
{telegramUsername}
{signalUsername}
availableChannels={availableCommsChannels}
disabled={loading}
onChannelChange={onVerificationChannelChange}
onEmailChange={onEmailChange}
onDiscordChange={onDiscordChange}
onTelegramChange={onTelegramChange}
onSignalChange={onSignalChange}
/>
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
@@ -1,64 +1,124 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { VerificationChannel } from '../../lib/migration/types'
import { api } from '../../lib/api'
import { _ } from '../../lib/i18n'
interface Props {
email: string
channel: VerificationChannel
identifier: string
token: string
loading: boolean
error: string | null
handle?: string
onTokenChange: (token: string) => void
onSubmit: (e: Event) => void
onResend: () => void
onVerified?: () => void
}
let {
email,
channel,
identifier,
token,
loading,
error,
handle,
onTokenChange,
onSubmit,
onResend,
onVerified,
}: Props = $props()
let telegramBotUsername = $state<string | undefined>(undefined)
let discordBotUsername = $state<string | undefined>(undefined)
let discordAppId = $state<string | undefined>(undefined)
const isTelegram = $derived(channel === 'telegram')
const isDiscord = $derived(channel === 'discord')
const isBotChannel = $derived(isTelegram || isDiscord)
onMount(async () => {
if (isBotChannel) {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
discordBotUsername = serverInfo.discordBotUsername
discordAppId = serverInfo.discordAppId
} catch {}
}
})
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'email'
case 'discord': return 'Discord'
case 'telegram': return 'Telegram'
case 'signal': return 'Signal'
default: return ch
}
}
</script>
<div class="step-content">
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p>
<div class="info-box">
<p>
{$_('migration.inbound.emailVerify.hint')}
</p>
</div>
{#if error}
<div class="message error">
{error}
{#if isTelegram && telegramBotUsername && handle}
{@const encodedHandle = handle.replaceAll('.', '_')}
<p>{$_('migration.inbound.emailVerify.telegramInstructions')}</p>
<div class="info-box">
<p>
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openTelegram')}</a>,
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code>
</p>
</div>
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
{:else if isDiscord && discordAppId && handle}
<p>{$_('migration.inbound.emailVerify.discordInstructions')}</p>
<div class="info-box">
<p>
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openDiscord')}</a>,
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong>
</p>
</div>
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
{:else}
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${identifier}</strong>`, channel: channelLabel(channel) } })}</p>
<div class="info-box">
<p>
{$_('migration.inbound.emailVerify.hint')}
</p>
</div>
{#if error}
<div class="message error">
{error}
</div>
{/if}
<form onsubmit={onSubmit}>
<div>
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
<input
id="email-verify-token"
type="text"
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
value={token}
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
disabled={loading}
required
/>
</div>
<div class="button-row">
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
{$_('migration.inbound.emailVerify.resend')}
</button>
<button type="submit" disabled={loading || !token}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
{/if}
<form onsubmit={onSubmit}>
<div>
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
<input
id="email-verify-token"
type="text"
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
value={token}
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
disabled={loading}
required
/>
</div>
<div class="button-row">
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
{$_('migration.inbound.emailVerify.resend')}
</button>
<button type="submit" disabled={loading || !token}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import type { InboundMigrationFlow } from '../../lib/migration'
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
import { getErrorMessage } from '../../lib/migration/types'
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
import { createPasskeyCredential, PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
import { _ } from '../../lib/i18n'
import ErrorStep from './ErrorStep.svelte'
import SuccessStep from './SuccessStep.svelte'
@@ -10,6 +11,9 @@
import EmailVerifyStep from './EmailVerifyStep.svelte'
import PasskeySetupStep from './PasskeySetupStep.svelte'
import AppPasswordStep from './AppPasswordStep.svelte'
import StepIndicator from './StepIndicator.svelte'
import ProgressStep from './ProgressStep.svelte'
import ReviewStep from './ReviewStep.svelte'
interface ResumeInfo {
direction: 'inbound'
@@ -38,26 +42,35 @@
let localPasswordInput = $state('')
let understood = $state(false)
let selectedDomain = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let selectedAuthMethod = $state<AuthMethod>('password')
let passkeyName = $state('')
let verifyingExistingHandle = $state(false)
let existingHandleError = $state<string | null>(null)
let sourcePdsDomains = $state<string[]>([])
const isResuming = $derived(flow.state.needsReauth === true)
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
function verificationIdentifier(): string {
return resolveVerificationIdentifier(
flow.state.verificationChannel,
flow.state.targetEmail,
flow.state.discordUsername,
flow.state.telegramUsername,
flow.state.signalUsername,
)
}
$effect(() => {
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
loadServerInfo()
}
if (flow.state.step === 'choose-handle') {
handleInput = ''
handleAvailable = null
existingHandleError = null
flow.updateField('handlePreservation', 'new')
flow.updateField('existingHandleVerified', false)
flow.loadSourcePdsDomains().then((d) => { sourcePdsDomains = d })
}
if (flow.state.step === 'source-handle' && resumeInfo) {
handleInput = resumeInfo.sourceHandle
@@ -79,8 +92,9 @@
$effect(() => {
if (flow.state.step === 'email-verify') {
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
const interval = setInterval(async () => {
if (flow.state.emailVerifyToken.trim()) return
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
await flow.checkEmailVerifiedAndProceed()
}, 3000)
return () => clearInterval(interval)
@@ -97,25 +111,6 @@
}
}
async function checkHandle() {
if (!handleInput.trim()) return
const fullHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
checkingHandle = true
handleAvailable = null
try {
handleAvailable = await flow.checkHandleAvailability(fullHandle)
} catch {
handleAvailable = true
} finally {
checkingHandle = false
}
}
function handlePreservationChange(preservation: HandlePreservation) {
flow.updateField('handlePreservation', preservation)
existingHandleError = null
@@ -224,43 +219,15 @@
flow.setError(null)
try {
if (!window.PublicKeyCredential) {
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
}
const { options } = await flow.startPasskeyRegistration()
const publicKeyOptions = prepareWebAuthnCreationOptions(
options as { publicKey: Record<string, unknown> }
const credential = await createPasskeyCredential(
() => flow.startPasskeyRegistration(),
)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
})
if (!credential) {
throw new Error('Passkey creation was cancelled')
}
const publicKeyCredential = credential as PublicKeyCredential
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
const credentialData = {
id: publicKeyCredential.id,
rawId: base64UrlEncode(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
clientDataJSON: base64UrlEncode(response.clientDataJSON),
attestationObject: base64UrlEncode(response.attestationObject),
},
}
await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
await flow.completePasskeyRegistration(credential, passkeyName || undefined)
} catch (err) {
const message = getErrorMessage(err)
if (message.includes('cancelled') || message.includes('AbortError')) {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError('Passkey registration was cancelled. Please try again.')
} else {
flow.setError(message)
flow.setError(getErrorMessage(err))
}
} finally {
loading = false
@@ -334,19 +301,7 @@
</script>
<div class="migration-wizard">
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
</div>
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
{#if flow.state.error}
<div class="message error">{flow.state.error}</div>
@@ -443,29 +398,37 @@
<ChooseHandleStep
{handleInput}
{selectedDomain}
{handleAvailable}
{checkingHandle}
email={flow.state.targetEmail}
password={flow.state.targetPassword}
authMethod={selectedAuthMethod}
inviteCode={flow.state.inviteCode}
{serverInfo}
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
verificationChannel={flow.state.verificationChannel}
discordUsername={flow.state.discordUsername}
telegramUsername={flow.state.telegramUsername}
signalUsername={flow.state.signalUsername}
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
migratingFromValue={flow.state.sourceHandle}
{loading}
sourceHandle={flow.state.sourceHandle}
sourceDid={flow.state.sourceDid}
{sourcePdsDomains}
handlePreservation={flow.state.handlePreservation}
existingHandleVerified={flow.state.existingHandleVerified}
{verifyingExistingHandle}
{existingHandleError}
checkAvailability={(h) => flow.checkHandleAvailability(h)}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
onEmailChange={(e) => flow.updateField('targetEmail', e)}
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
onAuthMethodChange={(m) => selectedAuthMethod = m}
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
onSignalChange={(v) => flow.updateField('signalUsername', v)}
onHandlePreservationChange={handlePreservationChange}
onVerifyExistingHandle={verifyExistingHandle}
onBack={() => flow.setStep('source-handle')}
@@ -473,88 +436,39 @@
/>
{:else if flow.state.step === 'review'}
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{$_('migration.inbound.review.desc')}</p>
<div class="review-card">
<div class="review-row">
<span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
<span class="value">{flow.state.sourceHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
<span class="value">{flow.state.targetHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.did')}:</span>
<span class="value mono">{flow.state.sourceDid}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
<span class="value">{flow.state.sourcePdsUrl}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
<span class="value">{window.location.origin}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.email')}:</span>
<span class="value">{flow.state.targetEmail}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
</div>
</div>
<div class="warning-box">
<ReviewStep
description={$_('migration.inbound.review.desc')}
rows={[
{ label: $_('migration.inbound.review.currentHandle'), value: flow.state.sourceHandle },
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
{ label: $_('migration.inbound.review.did'), value: flow.state.sourceDid, mono: true },
{ label: $_('migration.inbound.review.sourcePds'), value: flow.state.sourcePdsUrl },
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
]}
{loading}
onBack={() => flow.setStep('choose-handle')}
onContinue={startMigration}
>
{#snippet warning()}
{$_('migration.inbound.review.warning')}
</div>
<div class="button-row">
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={startMigration} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
{/snippet}
</ReviewStep>
{:else if flow.state.step === 'migrating'}
<div class="step-content">
<h2>{$_('migration.inbound.migrating.title')}</h2>
<p>{$_('migration.inbound.migrating.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.exportRepo')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.importRepo')}</span>
</div>
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.migratePrefs')}</span>
</div>
</div>
{#if flow.state.progress.blobsTotal > 0}
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
></div>
</div>
{/if}
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.migrating.title')}
description={$_('migration.inbound.migrating.desc')}
items={[
{ label: $_('migration.inbound.migrating.exportRepo'), completed: flow.state.progress.repoExported },
{ label: $_('migration.inbound.migrating.importRepo'), completed: flow.state.progress.repoImported },
{ label: `${$_('migration.inbound.migrating.migrateBlobs')} (${flow.state.progress.blobsMigrated}/${flow.state.progress.blobsTotal})`, completed: flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0, active: flow.state.progress.repoImported && !flow.state.progress.prefsMigrated },
{ label: $_('migration.inbound.migrating.migratePrefs'), completed: flow.state.progress.prefsMigrated },
]}
statusText={flow.state.progress.currentOperation}
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
/>
{:else if flow.state.step === 'passkey-setup'}
<PasskeySetupStep
@@ -575,7 +489,9 @@
{:else if flow.state.step === 'email-verify'}
<EmailVerifyStep
email={flow.state.targetEmail}
channel={flow.state.verificationChannel}
identifier={verificationIdentifier()}
handle={flow.state.targetHandle}
token={flow.state.emailVerifyToken}
{loading}
error={flow.state.error}
@@ -675,27 +591,16 @@
</div>
{:else if flow.state.step === 'finalizing'}
<div class="step-content">
<h2>{$_('migration.inbound.finalizing.title')}</h2>
<p>{$_('migration.inbound.finalizing.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.activated}>
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.activating')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.deactivating')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.finalizing.title')}
description={$_('migration.inbound.finalizing.desc')}
items={[
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
{ label: $_('migration.inbound.finalizing.deactivating'), completed: flow.state.progress.deactivated },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'success'}
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
@@ -1,8 +1,9 @@
<script lang="ts">
import type { OfflineInboundMigrationFlow } from '../../lib/migration'
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
import { getErrorMessage } from '../../lib/migration/types'
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
import { PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
import { _ } from '../../lib/i18n'
import ErrorStep from './ErrorStep.svelte'
import SuccessStep from './SuccessStep.svelte'
@@ -10,6 +11,9 @@
import EmailVerifyStep from './EmailVerifyStep.svelte'
import PasskeySetupStep from './PasskeySetupStep.svelte'
import AppPasswordStep from './AppPasswordStep.svelte'
import StepIndicator from './StepIndicator.svelte'
import ProgressStep from './ProgressStep.svelte'
import ReviewStep from './ReviewStep.svelte'
interface Props {
flow: OfflineInboundMigrationFlow
@@ -24,14 +28,22 @@
let understood = $state(false)
let handleInput = $state('')
let selectedDomain = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let validatingKey = $state(false)
let keyValid = $state<boolean | null>(null)
let fileInputRef = $state<HTMLInputElement | null>(null)
let selectedAuthMethod = $state<AuthMethod>('password')
let passkeyName = $state('')
function verificationIdentifier(): string {
return resolveVerificationIdentifier(
flow.state.verificationChannel,
flow.state.targetEmail,
flow.state.discordUsername,
flow.state.telegramUsername,
flow.state.signalUsername,
)
}
let redirectTriggered = $state(false)
$effect(() => {
@@ -40,7 +52,6 @@
}
if (flow.state.step === 'choose-handle') {
handleInput = ''
handleAvailable = null
}
})
@@ -55,8 +66,9 @@
$effect(() => {
if (flow.state.step === 'email-verify') {
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
const interval = setInterval(async () => {
if (flow.state.emailVerifyToken.trim()) return
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
await flow.checkEmailVerifiedAndProceed()
}, 3000)
return () => clearInterval(interval)
@@ -145,25 +157,6 @@
}
}
async function checkHandle() {
if (!handleInput.trim()) return
const fullHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
checkingHandle = true
handleAvailable = null
try {
handleAvailable = await flow.checkHandleAvailability(fullHandle)
} catch {
handleAvailable = true
} finally {
checkingHandle = false
}
}
function proceedToReview() {
const fullHandle = handleInput.includes('.')
? handleInput
@@ -203,17 +196,12 @@
flow.setError(null)
try {
if (!window.PublicKeyCredential) {
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
}
await flow.registerPasskey(passkeyName || undefined)
} catch (err) {
const message = getErrorMessage(err)
if (message.includes('cancelled') || message.includes('AbortError')) {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError('Passkey registration was cancelled. Please try again.')
} else {
flow.setError(message)
flow.setError(getErrorMessage(err))
}
} finally {
loading = false
@@ -233,19 +221,7 @@
</script>
<div class="migration-wizard">
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
</div>
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
{#if flow.state.error}
<div class="message error">{flow.state.error}</div>
@@ -401,13 +377,16 @@
<ChooseHandleStep
{handleInput}
{selectedDomain}
{handleAvailable}
{checkingHandle}
email={flow.state.targetEmail}
password={flow.state.targetPassword}
authMethod={selectedAuthMethod}
inviteCode={flow.state.inviteCode}
{serverInfo}
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
verificationChannel={flow.state.verificationChannel}
discordUsername={flow.state.discordUsername}
telegramUsername={flow.state.telegramUsername}
signalUsername={flow.state.signalUsername}
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
migratingFromValue={flow.state.userDid}
{loading}
@@ -417,128 +396,78 @@
existingHandleVerified={false}
verifyingExistingHandle={false}
existingHandleError={null}
checkAvailability={(h) => flow.checkHandleAvailability(h)}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
onEmailChange={(e) => flow.setTargetEmail(e)}
onPasswordChange={(p) => flow.setTargetPassword(p)}
onAuthMethodChange={(m) => selectedAuthMethod = m}
onInviteCodeChange={(c) => flow.setInviteCode(c)}
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
onSignalChange={(v) => flow.updateField('signalUsername', v)}
onBack={() => flow.setStep('provide-rotation-key')}
onContinue={proceedToReview}
/>
{:else if flow.state.step === 'review'}
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{$_('migration.offline.review.desc')}</p>
<div class="review-card">
<div class="review-row">
<span class="label">{$_('migration.inbound.review.did')}:</span>
<span class="value mono">{flow.state.userDid}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
<span class="value">{flow.state.targetHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.offline.review.carFile')}:</span>
<span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.offline.review.rotationKey')}:</span>
<span class="value mono">{flow.state.rotationKeyDidKey}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
<span class="value">{window.location.origin}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.email')}:</span>
<span class="value">{flow.state.targetEmail}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
</div>
</div>
<div class="warning-box">
<ReviewStep
description={$_('migration.offline.review.desc')}
rows={[
{ label: $_('migration.inbound.review.did'), value: flow.state.userDid, mono: true },
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
{ label: $_('migration.offline.review.carFile'), value: `${flow.state.carFileName} (${(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)` },
{ label: $_('migration.offline.review.rotationKey'), value: flow.state.rotationKeyDidKey, mono: true },
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
]}
{loading}
onBack={() => flow.setStep('choose-handle')}
onContinue={startMigration}
>
{#snippet warning()}
<strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
<p>{$_('migration.offline.review.plcWarning')}</p>
</div>
<div class="button-row">
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={startMigration} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
{/snippet}
</ReviewStep>
{:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
<div class="step-content">
<h2>{$_('migration.offline.migrating.title')}</h2>
<p>{$_('migration.offline.migrating.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
<span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
<span>{$_('migration.offline.migrating.creating')}</span>
</div>
<div class="progress-item" class:active={flow.state.step === 'importing'}>
<span class="icon"></span>
<span>{$_('migration.offline.migrating.importing')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.offline.migrating.title')}
description={$_('migration.offline.migrating.desc')}
items={[
{ label: $_('migration.offline.migrating.creating'), completed: flow.state.step !== 'creating', active: flow.state.step === 'creating' },
{ label: $_('migration.offline.migrating.importing'), completed: false, active: flow.state.step === 'importing' },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'migrating-blobs'}
<div class="step-content">
<h2>{$_('migration.offline.blobs.title')}</h2>
<p>{$_('migration.offline.blobs.desc')}</p>
<div class="progress-section">
<div class="progress-item completed">
<span class="icon"></span>
<span>{$_('migration.offline.migrating.importing')}</span>
</div>
<div class="progress-item active">
<span class="icon"></span>
<span>{$_('migration.offline.blobs.migrating')}</span>
</div>
</div>
{#if flow.state.progress.blobsTotal > 0}
<div class="blob-progress">
<div class="blob-progress-bar">
<div
class="blob-progress-fill"
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
></div>
</div>
<p class="blob-progress-text">
{flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
</p>
</div>
{/if}
<p class="status-text">{flow.state.progress.currentOperation}</p>
<ProgressStep
title={$_('migration.offline.blobs.title')}
description={$_('migration.offline.blobs.desc')}
items={[
{ label: $_('migration.offline.migrating.importing'), completed: true },
{ label: `${$_('migration.offline.blobs.migrating')} (${flow.state.progress.blobsMigrated}/${flow.state.progress.blobsTotal})`, completed: false, active: true },
]}
statusText={flow.state.progress.currentOperation}
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
>
{#if flow.state.progress.blobsFailed.length > 0}
<div class="warning-box">
<strong>{$_('migration.offline.blobs.failedTitle')}</strong>
<p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
</div>
{/if}
</div>
</ProgressStep>
{:else if flow.state.step === 'email-verify'}
<EmailVerifyStep
email={flow.state.targetEmail}
channel={flow.state.verificationChannel}
identifier={verificationIdentifier()}
handle={flow.state.targetHandle}
token={flow.state.emailVerifyToken}
{loading}
error={flow.state.error}
@@ -565,23 +494,15 @@
/>
{:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
<div class="step-content">
<h2>{$_('migration.inbound.finalizing.title')}</h2>
<p>{$_('migration.inbound.finalizing.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.activated}>
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.activating')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.finalizing.title')}
description={$_('migration.inbound.finalizing.desc')}
items={[
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'success'}
<SuccessStep
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface ProgressItem {
label: string
completed: boolean
active?: boolean
}
interface Props {
title: string
description: string
items: ProgressItem[]
statusText: string
progressBar?: { current: number; total: number }
children?: Snippet
}
let { title, description, items, statusText, progressBar, children }: Props = $props()
</script>
<div class="step-content">
<h2>{title}</h2>
<p>{description}</p>
<div class="progress-section">
{#each items as item}
<div class="progress-item" class:completed={item.completed} class:active={item.active}>
<span class="icon">{item.completed ? '✓' : '○'}</span>
<span>{item.label}</span>
</div>
{/each}
</div>
{#if progressBar && progressBar.total > 0}
<div class="progress-bar">
<div class="progress-fill" style="width: {(progressBar.current / progressBar.total) * 100}%"></div>
</div>
{/if}
<p class="status-text">{statusText}</p>
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { _ } from '../../lib/i18n'
interface ReviewRow {
label: string
value: string
mono?: boolean
}
interface Props {
description: string
rows: ReviewRow[]
loading: boolean
onBack: () => void
onContinue: () => void
warning?: Snippet
}
let { description, rows, loading, onBack, onContinue, warning }: Props = $props()
</script>
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{description}</p>
<div class="review-card">
{#each rows as row}
<div class="review-row">
<span class="label">{row.label}:</span>
<span class="value" class:mono={row.mono}>{row.value}</span>
</div>
{/each}
</div>
{#if warning}
<div class="warning-box">
{@render warning()}
</div>
{/if}
<div class="button-row">
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={onContinue} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
steps: string[]
currentIndex: number
}
let { steps, currentIndex }: Props = $props()
</script>
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === currentIndex} class:completed={i < currentIndex}>
<div class="step-dot">{i < currentIndex ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < currentIndex}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[currentIndex]}</strong> · Step {currentIndex + 1} of {steps.length}
</div>
-440
View File
@@ -1,440 +0,0 @@
import { z } from "zod";
import { err, ok, type Result } from "./types/result.ts";
import { ApiError } from "./api.ts";
import type {
AccessToken,
Did,
Nsid,
RefreshToken,
Rkey,
} from "./types/branded.ts";
import {
accountInfoSchema,
appPasswordSchema,
createdAppPasswordSchema,
createRecordResponseSchema,
didDocumentSchema,
enableTotpResponseSchema,
legacyLoginPreferenceSchema,
listPasskeysResponseSchema,
listRecordsResponseSchema,
listSessionsResponseSchema,
listTrustedDevicesResponseSchema,
notificationPrefsSchema,
passwordStatusSchema,
reauthStatusSchema,
recordResponseSchema,
repoDescriptionSchema,
searchAccountsResponseSchema,
serverConfigSchema,
serverDescriptionSchema,
serverStatsSchema,
sessionSchema,
successResponseSchema,
totpSecretSchema,
totpStatusSchema,
type ValidatedAccountInfo,
type ValidatedAppPassword,
type ValidatedCreatedAppPassword,
type ValidatedCreateRecordResponse,
type ValidatedDidDocument,
type ValidatedEnableTotpResponse,
type ValidatedLegacyLoginPreference,
type ValidatedListPasskeysResponse,
type ValidatedListRecordsResponse,
type ValidatedListSessionsResponse,
type ValidatedListTrustedDevicesResponse,
type ValidatedNotificationPrefs,
type ValidatedPasswordStatus,
type ValidatedReauthStatus,
type ValidatedRecordResponse,
type ValidatedRepoDescription,
type ValidatedSearchAccountsResponse,
type ValidatedServerConfig,
type ValidatedServerDescription,
type ValidatedServerStats,
type ValidatedSession,
type ValidatedSuccessResponse,
type ValidatedTotpSecret,
type ValidatedTotpStatus,
} from "./types/schemas.ts";
const API_BASE = "/xrpc";
interface XrpcOptions {
method?: "GET" | "POST";
params?: Record<string, string>;
body?: unknown;
token?: string;
}
class ValidationError extends Error {
constructor(
public issues: z.ZodIssue[],
message: string = "API response validation failed",
) {
super(message);
this.name = "ValidationError";
}
}
async function xrpcValidated<T>(
method: string,
schema: z.ZodType<T>,
options?: XrpcOptions,
): Promise<Result<T, ApiError | ValidationError>> {
const { method: httpMethod = "GET", params, body, token } = options ?? {};
let url = `${API_BASE}/${method}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams}`;
}
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
if (body) {
headers["Content-Type"] = "application/json";
}
try {
const res = await fetch(url, {
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
return err(new ApiError(res.status, errData.error, errData.message));
}
const data = await res.json();
const parsed = schema.safeParse(data);
if (!parsed.success) {
return err(new ValidationError(parsed.error.issues));
}
return ok(parsed.data);
} catch (e) {
if (e instanceof ApiError || e instanceof ValidationError) {
return err(e);
}
return err(
new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)),
);
}
}
export const validatedApi = {
getSession(
token: AccessToken,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.getSession", sessionSchema, {
token,
});
},
refreshSession(
refreshJwt: RefreshToken,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.refreshSession", sessionSchema, {
method: "POST",
token: refreshJwt,
});
},
createSession(
identifier: string,
password: string,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.createSession", sessionSchema, {
method: "POST",
body: { identifier, password },
});
},
describeServer(): Promise<
Result<ValidatedServerDescription, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.server.describeServer",
serverDescriptionSchema,
);
},
listAppPasswords(
token: AccessToken,
): Promise<
Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.server.listAppPasswords",
z.object({ passwords: z.array(appPasswordSchema) }),
{ token },
);
},
createAppPassword(
token: AccessToken,
name: string,
scopes?: string,
): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.server.createAppPassword",
createdAppPasswordSchema,
{
method: "POST",
token,
body: { name, scopes },
},
);
},
listSessions(
token: AccessToken,
): Promise<
Result<ValidatedListSessionsResponse, ApiError | ValidationError>
> {
return xrpcValidated("_account.listSessions", listSessionsResponseSchema, {
token,
});
},
getTotpStatus(
token: AccessToken,
): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.getTotpStatus", totpStatusSchema, {
token,
});
},
createTotpSecret(
token: AccessToken,
): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.server.createTotpSecret",
totpSecretSchema,
{
method: "POST",
token,
},
);
},
enableTotp(
token: AccessToken,
code: string,
): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.server.enableTotp",
enableTotpResponseSchema,
{
method: "POST",
token,
body: { code },
},
);
},
listPasskeys(
token: AccessToken,
): Promise<
Result<ValidatedListPasskeysResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.server.listPasskeys",
listPasskeysResponseSchema,
{ token },
);
},
listTrustedDevices(
token: AccessToken,
): Promise<
Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"_account.listTrustedDevices",
listTrustedDevicesResponseSchema,
{ token },
);
},
getReauthStatus(
token: AccessToken,
): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> {
return xrpcValidated("_account.getReauthStatus", reauthStatusSchema, {
token,
});
},
getNotificationPrefs(
token: AccessToken,
): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> {
return xrpcValidated(
"_account.getNotificationPrefs",
notificationPrefsSchema,
{ token },
);
},
getDidDocument(
token: AccessToken,
): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> {
return xrpcValidated("_account.getDidDocument", didDocumentSchema, {
token,
});
},
describeRepo(
token: AccessToken,
repo: Did,
): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.repo.describeRepo",
repoDescriptionSchema,
{
token,
params: { repo },
},
);
},
listRecords(
token: AccessToken,
repo: Did,
collection: Nsid,
options?: { limit?: number; cursor?: string; reverse?: boolean },
): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> {
const params: Record<string, string> = { repo, collection };
if (options?.limit) params.limit = String(options.limit);
if (options?.cursor) params.cursor = options.cursor;
if (options?.reverse) params.reverse = "true";
return xrpcValidated(
"com.atproto.repo.listRecords",
listRecordsResponseSchema,
{
token,
params,
},
);
},
getRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey,
): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.repo.getRecord", recordResponseSchema, {
token,
params: { repo, collection, rkey },
});
},
createRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
record: unknown,
rkey?: Rkey,
): Promise<
Result<ValidatedCreateRecordResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.repo.createRecord",
createRecordResponseSchema,
{
method: "POST",
token,
body: { repo, collection, record, rkey },
},
);
},
getServerStats(
token: AccessToken,
): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> {
return xrpcValidated("_admin.getServerStats", serverStatsSchema, { token });
},
getServerConfig(): Promise<
Result<ValidatedServerConfig, ApiError | ValidationError>
> {
return xrpcValidated("_server.getConfig", serverConfigSchema);
},
getPasswordStatus(
token: AccessToken,
): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> {
return xrpcValidated("_account.getPasswordStatus", passwordStatusSchema, {
token,
});
},
changePassword(
token: AccessToken,
currentPassword: string,
newPassword: string,
): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> {
return xrpcValidated("_account.changePassword", successResponseSchema, {
method: "POST",
token,
body: { currentPassword, newPassword },
});
},
getLegacyLoginPreference(
token: AccessToken,
): Promise<
Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>
> {
return xrpcValidated(
"_account.getLegacyLoginPreference",
legacyLoginPreferenceSchema,
{ token },
);
},
getAccountInfo(
token: AccessToken,
did: Did,
): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.admin.getAccountInfo",
accountInfoSchema,
{
token,
params: { did },
},
);
},
searchAccounts(
token: AccessToken,
options?: { handle?: string; cursor?: string; limit?: number },
): Promise<
Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>
> {
const params: Record<string, string> = {};
if (options?.handle) params.handle = options.handle;
if (options?.cursor) params.cursor = options.cursor;
if (options?.limit) params.limit = String(options.limit);
return xrpcValidated(
"com.atproto.admin.searchAccounts",
searchAccountsResponseSchema,
{
token,
params,
},
);
},
};
export { ValidationError };
+25 -644
View File
@@ -86,6 +86,18 @@ import type {
const API_BASE = "/xrpc";
const STATUS_FALLBACK_MESSAGE: Record<number, string> = {
400: "Bad request",
401: "Authentication required",
403: "Forbidden",
404: "Not found",
429: "Rate limit exceeded",
500: "Internal server error",
502: "Bad gateway",
503: "Service unavailable",
504: "Gateway timeout",
};
export class ApiError extends Error {
public did?: Did;
public reauthMethods?: string[];
@@ -96,7 +108,7 @@ export class ApiError extends Error {
did?: string,
reauthMethods?: string[],
) {
super(message);
super(message ?? STATUS_FALLBACK_MESSAGE[status] ?? "Request failed");
this.name = "ApiError";
this.did = did ? unsafeAsDid(did) : undefined;
this.reauthMethods = reauthMethods;
@@ -429,9 +441,13 @@ export const api = {
params: {
did: Did;
handle: Handle;
email: EmailAddress;
email?: EmailAddress;
password: string;
inviteCode?: string;
verificationChannel?: string;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
},
): Promise<Session> {
const url = `${API_BASE}/com.atproto.server.createAccount`;
@@ -447,6 +463,10 @@ export const api = {
email: params.email,
password: params.password,
inviteCode: params.inviteCode,
verificationChannel: params.verificationChannel,
discordUsername: params.discordUsername,
telegramUsername: params.telegramUsername,
signalUsername: params.signalUsername,
}),
});
const data = await response.json();
@@ -505,15 +525,6 @@ export const api = {
});
},
checkCommsChannelInUse(
channel: "email" | "discord" | "telegram" | "signal",
identifier: string,
): Promise<{ inUse: boolean }> {
return xrpc("_account.checkCommsChannelInUse", {
method: "POST",
body: { channel, identifier },
});
},
async getSession(token: AccessToken): Promise<Session> {
const raw = await xrpc<unknown>("com.atproto.server.getSession", { token });
@@ -1219,11 +1230,12 @@ export const api = {
},
resendMigrationVerification(
email: EmailAddress,
channel: string,
identifier: string,
): Promise<ResendMigrationVerificationResponse> {
return xrpc("com.atproto.server.resendMigrationVerification", {
method: "POST",
body: { email },
body: { channel, identifier },
});
},
@@ -1497,634 +1509,3 @@ export const api = {
},
};
export const typedApi = {
createSession(
identifier: string,
password: string,
): Promise<Result<Session, ApiError>> {
return xrpcResult<Session>("com.atproto.server.createSession", {
method: "POST",
body: { identifier, password },
}).then((r) => r.ok ? ok(castSession(r.value)) : r);
},
getSession(token: AccessToken): Promise<Result<Session, ApiError>> {
return xrpcResult<Session>("com.atproto.server.getSession", { token })
.then((r) => r.ok ? ok(castSession(r.value)) : r);
},
refreshSession(refreshJwt: RefreshToken): Promise<Result<Session, ApiError>> {
return xrpcResult<Session>("com.atproto.server.refreshSession", {
method: "POST",
token: refreshJwt,
}).then((r) => r.ok ? ok(castSession(r.value)) : r);
},
describeServer(): Promise<Result<ServerDescription, ApiError>> {
return xrpcResult("com.atproto.server.describeServer");
},
listAppPasswords(
token: AccessToken,
): Promise<Result<{ passwords: AppPassword[] }, ApiError>> {
return xrpcResult("com.atproto.server.listAppPasswords", { token });
},
createAppPassword(
token: AccessToken,
name: string,
scopes?: string,
): Promise<Result<CreatedAppPassword, ApiError>> {
return xrpcResult("com.atproto.server.createAppPassword", {
method: "POST",
token,
body: { name, scopes },
});
},
revokeAppPassword(
token: AccessToken,
name: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.revokeAppPassword", {
method: "POST",
token,
body: { name },
});
},
listSessions(
token: AccessToken,
): Promise<Result<ListSessionsResponse, ApiError>> {
return xrpcResult("_account.listSessions", { token });
},
revokeSession(
token: AccessToken,
sessionId: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("_account.revokeSession", {
method: "POST",
token,
body: { sessionId },
});
},
getTotpStatus(token: AccessToken): Promise<Result<TotpStatus, ApiError>> {
return xrpcResult("com.atproto.server.getTotpStatus", { token });
},
createTotpSecret(token: AccessToken): Promise<Result<TotpSecret, ApiError>> {
return xrpcResult("com.atproto.server.createTotpSecret", {
method: "POST",
token,
});
},
enableTotp(
token: AccessToken,
code: string,
): Promise<Result<EnableTotpResponse, ApiError>> {
return xrpcResult("com.atproto.server.enableTotp", {
method: "POST",
token,
body: { code },
});
},
disableTotp(
token: AccessToken,
password: string,
code: string,
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("com.atproto.server.disableTotp", {
method: "POST",
token,
body: { password, code },
});
},
listPasskeys(
token: AccessToken,
): Promise<Result<ListPasskeysResponse, ApiError>> {
return xrpcResult("com.atproto.server.listPasskeys", { token });
},
deletePasskey(
token: AccessToken,
id: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.deletePasskey", {
method: "POST",
token,
body: { id },
});
},
listTrustedDevices(
token: AccessToken,
): Promise<Result<ListTrustedDevicesResponse, ApiError>> {
return xrpcResult("_account.listTrustedDevices", { token });
},
getReauthStatus(token: AccessToken): Promise<Result<ReauthStatus, ApiError>> {
return xrpcResult("_account.getReauthStatus", { token });
},
getNotificationPrefs(
token: AccessToken,
): Promise<Result<NotificationPrefs, ApiError>> {
return xrpcResult("_account.getNotificationPrefs", { token });
},
updateHandle(
token: AccessToken,
handle: Handle,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.identity.updateHandle", {
method: "POST",
token,
body: { handle },
});
},
describeRepo(
token: AccessToken,
repo: Did,
): Promise<Result<RepoDescription, ApiError>> {
return xrpcResult("com.atproto.repo.describeRepo", {
token,
params: { repo },
});
},
listRecords(
token: AccessToken,
repo: Did,
collection: Nsid,
options?: { limit?: number; cursor?: string; reverse?: boolean },
): Promise<Result<ListRecordsResponse, ApiError>> {
const params: Record<string, string> = { repo, collection };
if (options?.limit) params.limit = String(options.limit);
if (options?.cursor) params.cursor = options.cursor;
if (options?.reverse) params.reverse = "true";
return xrpcResult("com.atproto.repo.listRecords", { token, params });
},
getRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey,
): Promise<Result<RecordResponse, ApiError>> {
return xrpcResult("com.atproto.repo.getRecord", {
token,
params: { repo, collection, rkey },
});
},
deleteRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.repo.deleteRecord", {
method: "POST",
token,
body: { repo, collection, rkey },
});
},
searchAccounts(
token: AccessToken,
options?: { handle?: string; cursor?: string; limit?: number },
): Promise<Result<SearchAccountsResponse, ApiError>> {
const params: Record<string, string> = {};
if (options?.handle) params.handle = options.handle;
if (options?.cursor) params.cursor = options.cursor;
if (options?.limit) params.limit = String(options.limit);
return xrpcResult("com.atproto.admin.searchAccounts", { token, params });
},
getAccountInfo(
token: AccessToken,
did: Did,
): Promise<Result<AccountInfo, ApiError>> {
return xrpcResult("com.atproto.admin.getAccountInfo", {
token,
params: { did },
});
},
getServerStats(token: AccessToken): Promise<Result<ServerStats, ApiError>> {
return xrpcResult("_admin.getServerStats", { token });
},
getDidDocument(token: AccessToken): Promise<Result<DidDocument, ApiError>> {
return xrpcResult("_account.getDidDocument", { token });
},
deleteSession(token: AccessToken): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.deleteSession", {
method: "POST",
token,
});
},
revokeAllSessions(
token: AccessToken,
): Promise<Result<{ revokedCount: number }, ApiError>> {
return xrpcResult("_account.revokeAllSessions", {
method: "POST",
token,
});
},
getAccountInviteCodes(
token: AccessToken,
): Promise<Result<{ codes: InviteCodeInfo[] }, ApiError>> {
return xrpcResult("com.atproto.server.getAccountInviteCodes", { token });
},
createInviteCode(
token: AccessToken,
useCount: number = 1,
): Promise<Result<{ code: string }, ApiError>> {
return xrpcResult("com.atproto.server.createInviteCode", {
method: "POST",
token,
body: { useCount },
});
},
changePassword(
token: AccessToken,
currentPassword: string,
newPassword: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("_account.changePassword", {
method: "POST",
token,
body: { currentPassword, newPassword },
});
},
getPasswordStatus(
token: AccessToken,
): Promise<Result<PasswordStatus, ApiError>> {
return xrpcResult("_account.getPasswordStatus", { token });
},
getServerConfig(): Promise<Result<ServerConfig, ApiError>> {
return xrpcResult("_server.getConfig");
},
getLegacyLoginPreference(
token: AccessToken,
): Promise<Result<LegacyLoginPreference, ApiError>> {
return xrpcResult("_account.getLegacyLoginPreference", { token });
},
updateLegacyLoginPreference(
token: AccessToken,
allowLegacyLogin: boolean,
): Promise<Result<UpdateLegacyLoginResponse, ApiError>> {
return xrpcResult("_account.updateLegacyLoginPreference", {
method: "POST",
token,
body: { allowLegacyLogin },
});
},
getNotificationHistory(
token: AccessToken,
): Promise<Result<NotificationHistoryResponse, ApiError>> {
return xrpcResult("_account.getNotificationHistory", { token });
},
updateNotificationPrefs(
token: AccessToken,
prefs: {
preferredChannel?: string;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
},
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
return xrpcResult("_account.updateNotificationPrefs", {
method: "POST",
token,
body: prefs,
});
},
revokeTrustedDevice(
token: AccessToken,
deviceId: string,
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("_account.revokeTrustedDevice", {
method: "POST",
token,
body: { deviceId },
});
},
updateTrustedDevice(
token: AccessToken,
deviceId: string,
friendlyName: string,
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("_account.updateTrustedDevice", {
method: "POST",
token,
body: { deviceId, friendlyName },
});
},
reauthPassword(
token: AccessToken,
password: string,
): Promise<Result<ReauthResponse, ApiError>> {
return xrpcResult("_account.reauthPassword", {
method: "POST",
token,
body: { password },
});
},
reauthTotp(
token: AccessToken,
code: string,
): Promise<Result<ReauthResponse, ApiError>> {
return xrpcResult("_account.reauthTotp", {
method: "POST",
token,
body: { code },
});
},
reauthPasskeyStart(
token: AccessToken,
): Promise<Result<ReauthPasskeyStartResponse, ApiError>> {
return xrpcResult("_account.reauthPasskeyStart", {
method: "POST",
token,
});
},
reauthPasskeyFinish(
token: AccessToken,
credential: unknown,
): Promise<Result<ReauthResponse, ApiError>> {
return xrpcResult("_account.reauthPasskeyFinish", {
method: "POST",
token,
body: { credential },
});
},
confirmSignup(
did: Did,
verificationCode: string,
): Promise<Result<ConfirmSignupResult, ApiError>> {
return xrpcResult("com.atproto.server.confirmSignup", {
method: "POST",
body: { did, verificationCode },
});
},
resendVerification(
did: Did,
): Promise<Result<{ success: boolean }, ApiError>> {
return xrpcResult("com.atproto.server.resendVerification", {
method: "POST",
body: { did },
});
},
requestEmailUpdate(
token: AccessToken,
): Promise<Result<EmailUpdateResponse, ApiError>> {
return xrpcResult("com.atproto.server.requestEmailUpdate", {
method: "POST",
token,
});
},
updateEmail(
token: AccessToken,
email: string,
emailToken?: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.updateEmail", {
method: "POST",
token,
body: { email, token: emailToken },
});
},
requestAccountDelete(token: AccessToken): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.requestAccountDelete", {
method: "POST",
token,
});
},
deleteAccount(
did: Did,
password: string,
deleteToken: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.deleteAccount", {
method: "POST",
body: { did, password, token: deleteToken },
});
},
updateDidDocument(
token: AccessToken,
params: {
verificationMethods?: VerificationMethod[];
alsoKnownAs?: string[];
serviceEndpoint?: string;
},
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("_account.updateDidDocument", {
method: "POST",
token,
body: params,
});
},
deactivateAccount(
token: AccessToken,
deleteAfter?: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.deactivateAccount", {
method: "POST",
token,
body: { deleteAfter },
});
},
activateAccount(token: AccessToken): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.activateAccount", {
method: "POST",
token,
});
},
createRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
record: unknown,
rkey?: Rkey,
): Promise<Result<CreateRecordResponse, ApiError>> {
return xrpcResult("com.atproto.repo.createRecord", {
method: "POST",
token,
body: { repo, collection, record, rkey },
});
},
putRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey,
record: unknown,
): Promise<Result<CreateRecordResponse, ApiError>> {
return xrpcResult("com.atproto.repo.putRecord", {
method: "POST",
token,
body: { repo, collection, rkey, record },
});
},
getInviteCodes(
token: AccessToken,
options?: { sort?: "recent" | "usage"; cursor?: string; limit?: number },
): Promise<Result<GetInviteCodesResponse, ApiError>> {
const params: Record<string, string> = {};
if (options?.sort) params.sort = options.sort;
if (options?.cursor) params.cursor = options.cursor;
if (options?.limit) params.limit = String(options.limit);
return xrpcResult("com.atproto.admin.getInviteCodes", { token, params });
},
disableAccountInvites(
token: AccessToken,
account: Did,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.admin.disableAccountInvites", {
method: "POST",
token,
body: { account },
});
},
enableAccountInvites(
token: AccessToken,
account: Did,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.admin.enableAccountInvites", {
method: "POST",
token,
body: { account },
});
},
adminDeleteAccount(
token: AccessToken,
did: Did,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.admin.deleteAccount", {
method: "POST",
token,
body: { did },
});
},
startPasskeyRegistration(
token: AccessToken,
friendlyName?: string,
): Promise<Result<StartPasskeyRegistrationResponse, ApiError>> {
return xrpcResult("com.atproto.server.startPasskeyRegistration", {
method: "POST",
token,
body: { friendlyName },
});
},
finishPasskeyRegistration(
token: AccessToken,
credential: unknown,
friendlyName?: string,
): Promise<Result<FinishPasskeyRegistrationResponse, ApiError>> {
return xrpcResult("com.atproto.server.finishPasskeyRegistration", {
method: "POST",
token,
body: { credential, friendlyName },
});
},
updatePasskey(
token: AccessToken,
id: string,
friendlyName: string,
): Promise<Result<void, ApiError>> {
return xrpcResult<void>("com.atproto.server.updatePasskey", {
method: "POST",
token,
body: { id, friendlyName },
});
},
regenerateBackupCodes(
token: AccessToken,
password: string,
code: string,
): Promise<Result<RegenerateBackupCodesResponse, ApiError>> {
return xrpcResult("com.atproto.server.regenerateBackupCodes", {
method: "POST",
token,
body: { password, code },
});
},
updateLocale(
token: AccessToken,
preferredLocale: string,
): Promise<Result<UpdateLocaleResponse, ApiError>> {
return xrpcResult("_account.updateLocale", {
method: "POST",
token,
body: { preferredLocale },
});
},
confirmChannelVerification(
token: AccessToken,
channel: string,
identifier: string,
code: string,
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("_account.confirmChannelVerification", {
method: "POST",
token,
body: { channel, identifier, code },
});
},
removePassword(
token: AccessToken,
): Promise<Result<SuccessResponse, ApiError>> {
return xrpcResult("_account.removePassword", {
method: "POST",
token,
});
},
};
+8 -9
View File
@@ -1,4 +1,4 @@
import { api, ApiError, castSession, typedApi } from "./api.ts";
import { api, ApiError, castSession } from "./api.ts";
import type {
CreateAccountParams,
CreateAccountResult,
@@ -15,7 +15,6 @@ import {
unsafeAsRefreshToken,
} from "./types/branded.ts";
import { err, isErr, isOk, ok, type Result } from "./types/result.ts";
import { assertNever } from "./types/exhaustive.ts";
import {
checkForOAuthCallback,
clearAllOAuthState,
@@ -392,15 +391,15 @@ export async function login(
: null;
setLoading(previousSession);
const result = await typedApi.createSession(identifier, password);
if (isErr(result)) {
const error = toAuthError(result.error);
try {
const session = await api.createSession(identifier, password);
setAuthenticated(session);
return ok(session);
} catch (e) {
const error = toAuthError(e);
setError(error);
return err(error);
}
setAuthenticated(result.value);
return ok(result.value);
}
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
@@ -654,7 +653,7 @@ export function matchAuthState<T>(handlers: {
case "error":
return handlers.error(current.error, current.savedAccounts);
default:
return assertNever(current);
throw new Error(`Unexpected auth state: ${(current as { kind: string }).kind}`);
}
}
@@ -0,0 +1,29 @@
export interface EmailVerificationDeps {
checkVerified: () => Promise<boolean>;
onVerified: () => Promise<void>;
}
export function createEmailVerificationPoller(
deps: EmailVerificationDeps,
): { checkAndAdvance: () => Promise<boolean> } {
let checking = false;
return {
async checkAndAdvance(): Promise<boolean> {
if (checking) return false;
checking = true;
try {
const verified = await deps.checkVerified();
if (!verified) return false;
await deps.onVerified();
return true;
} catch {
return false;
} finally {
checking = false;
}
},
};
}
@@ -0,0 +1,56 @@
import type {
MigrationProgress,
ServerDescription,
VerificationChannel,
} from "../migration/types.ts";
import type { AtprotoClient } from "../migration/atproto-client.ts";
export function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
export async function checkHandleAvailabilityViaClient(
client: AtprotoClient,
handle: string,
): Promise<boolean> {
try {
await client.resolveHandle(handle);
return false;
} catch {
return true;
}
}
export function resolveVerificationIdentifier(
channel: VerificationChannel,
email: string,
discordUsername: string,
telegramUsername: string,
signalUsername: string,
): string {
switch (channel) {
case "email": return email;
case "discord": return discordUsername;
case "telegram": return telegramUsername;
case "signal": return signalUsername;
}
}
export async function loadServerInfo(
client: AtprotoClient,
cached: ServerDescription | null,
): Promise<ServerDescription> {
if (cached) return cached;
return client.describeServer();
}
@@ -0,0 +1,54 @@
import {
type CredentialAttestationJSON,
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from "../webauthn.ts";
export class PasskeyCancelledError extends Error {
constructor() {
super("Passkey creation was cancelled");
this.name = "PasskeyCancelledError";
}
}
export async function createPasskeyCredential(
startRegistration: () => Promise<{ options: unknown }>,
): Promise<CredentialAttestationJSON> {
if (!globalThis.PublicKeyCredential) {
throw new Error("Passkeys are not supported in this browser");
}
const { options } = await startRegistration();
const publicKeyOptions = prepareCreationOptions(
options as unknown as WebAuthnCreationOptionsResponse,
);
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential) {
throw new PasskeyCancelledError();
}
return serializeAttestationResponse(credential as PublicKeyCredential);
}
export interface PasskeyRegistrationApi {
startRegistration(): Promise<{ options: unknown }>;
completeSetup(
credential: CredentialAttestationJSON,
name?: string,
): Promise<{ appPassword: string; appPasswordName: string }>;
}
export async function performPasskeyRegistration(
passkeyApi: PasskeyRegistrationApi,
friendlyName?: string,
): Promise<{ appPassword: string; appPasswordName: string }> {
const serialized = await createPasskeyCredential(
passkeyApi.startRegistration,
);
return passkeyApi.completeSetup(serialized, friendlyName);
}
+20 -52
View File
@@ -603,6 +603,20 @@ export class AtprotoClient {
return result.verified;
}
async checkChannelVerified(
did: string,
channel: string,
): Promise<boolean> {
const result = await this.xrpc<{ verified: boolean }>(
"_checkChannelVerified",
{
httpMethod: "POST",
body: { did, channel },
},
);
return result.verified;
}
async verifyToken(
token: string,
identifier: string,
@@ -625,9 +639,13 @@ export class AtprotoClient {
});
}
async resendMigrationVerification(): Promise<void> {
async resendMigrationVerification(
channel: string,
identifier: string,
): Promise<void> {
await this.xrpc("com.atproto.server.resendMigrationVerification", {
httpMethod: "POST",
body: { channel, identifier },
});
}
@@ -731,23 +749,7 @@ export async function getOAuthServerMetadata(
}
}
export async function generatePKCE(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = base64UrlEncode(array);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const codeChallenge = base64UrlEncode(new Uint8Array(digest));
return { codeVerifier, codeChallenge };
}
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
@@ -758,34 +760,6 @@ export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
);
}
export function base64UrlDecode(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
export function prepareWebAuthnCreationOptions(
options: { publicKey: Record<string, unknown> },
): PublicKeyCredentialCreationOptions {
const pk = options.publicKey;
return {
...pk,
challenge: base64UrlDecode(pk.challenge as string),
user: {
...(pk.user as Record<string, unknown>),
id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
},
excludeCredentials:
((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
(cred) => ({
...cred,
id: base64UrlDecode(cred.id as string),
}),
),
} as unknown as PublicKeyCredentialCreationOptions;
}
async function computeAccessTokenHash(accessToken: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(accessToken);
@@ -793,12 +767,6 @@ async function computeAccessTokenHash(accessToken: string): Promise<string> {
return base64UrlEncode(new Uint8Array(hash));
}
export function generateOAuthState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export function buildOAuthAuthorizationUrl(
metadata: OAuthServerMetadata,
params: {
+83 -57
View File
@@ -12,8 +12,6 @@ import {
createLocalClient,
exchangeOAuthCode,
generateDPoPKeyPair,
generateOAuthState,
generatePKCE,
getMigrationOAuthClientId,
getMigrationOAuthRedirectUri,
getOAuthServerMetadata,
@@ -22,6 +20,11 @@ import {
resolvePdsUrl,
saveDPoPKey,
} from "./atproto-client.ts";
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from "../oauth.ts";
import {
clearMigrationState,
saveMigrationState,
@@ -40,20 +43,13 @@ function migrationLog(stage: string, data?: Record<string, unknown>) {
}
}
function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
import {
createInitialProgress,
checkHandleAvailabilityViaClient,
loadServerInfo,
resolveVerificationIdentifier,
} from "../flows/migration-shared.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
export function createInboundMigrationFlow() {
let state = $state<InboundMigrationState>({
@@ -82,11 +78,16 @@ export function createInboundMigrationFlow() {
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
});
let sourceClient: AtprotoClient | null = null;
let localClient: AtprotoClient | null = null;
let localServerInfo: ServerDescription | null = null;
let sourcePdsDomains: string[] = [];
function setStep(step: InboundStep) {
state.step = step;
@@ -113,10 +114,9 @@ export function createInboundMigrationFlow() {
if (!localClient) {
localClient = createLocalClient();
}
if (!localServerInfo) {
localServerInfo = await localClient.describeServer();
}
return localServerInfo;
const info = await loadServerInfo(localClient, localServerInfo);
localServerInfo = info;
return info;
}
async function resolveSourcePds(handle: string): Promise<void> {
@@ -147,8 +147,9 @@ export function createInboundMigrationFlow() {
);
}
const { codeVerifier, codeChallenge } = await generatePKCE();
const oauthState = generateOAuthState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const oauthState = generateState();
const dpopKeyPair = await generateDPoPKeyPair();
await saveDPoPKey(dpopKeyPair);
@@ -314,16 +315,23 @@ export function createInboundMigrationFlow() {
saveMigrationState(state);
}
async function loadSourcePdsDomains(): Promise<string[]> {
if (sourcePdsDomains.length > 0) return sourcePdsDomains;
if (!sourceClient) return [];
try {
const info = await sourceClient.describeServer();
sourcePdsDomains = info.availableUserDomains;
} catch {
sourcePdsDomains = [];
}
return sourcePdsDomains;
}
async function checkHandleAvailability(handle: string): Promise<boolean> {
if (!localClient) {
localClient = createLocalClient();
}
try {
await localClient.resolveHandle(handle);
return false;
} catch {
return true;
}
return checkHandleAvailabilityViaClient(localClient, handle);
}
async function verifyExistingHandle(): Promise<{
@@ -401,8 +409,12 @@ export function createInboundMigrationFlow() {
const passkeyParams = {
did: state.sourceDid,
handle: state.targetHandle,
email: state.targetEmail,
email: state.targetEmail || undefined,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
};
migrationLog("startMigration: Creating passkey account on NEW PDS", {
@@ -428,9 +440,13 @@ export function createInboundMigrationFlow() {
const accountParams = {
did: state.sourceDid,
handle: state.targetHandle,
email: state.targetEmail,
email: state.targetEmail || undefined,
password: state.targetPassword,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
};
migrationLog("startMigration: Creating account on NEW PDS", {
@@ -618,30 +634,40 @@ export function createInboundMigrationFlow() {
if (!localClient) {
localClient = createLocalClient();
}
await localClient.resendMigrationVerification();
await localClient.resendMigrationVerification(
state.verificationChannel,
resolveVerificationIdentifier(
state.verificationChannel,
state.targetEmail,
state.discordUsername,
state.telegramUsername,
state.signalUsername,
),
);
}
let checkingEmailVerification = false;
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
if (checkingEmailVerification) return false;
if (!localClient) return false;
checkingEmailVerification = true;
try {
const verified = await localClient.checkEmailVerified(state.targetEmail);
if (!verified) return false;
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (!localClient) return false;
if (state.verificationChannel === "email") {
return localClient.checkEmailVerified(state.targetEmail);
}
return localClient.checkChannelVerified(
state.sourceDid,
state.verificationChannel,
);
},
async onVerified() {
if (state.authMethod === "passkey") {
migrationLog(
"checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup",
);
setStep("passkey-setup");
return true;
return;
}
if (!localClient.getAccessToken()) {
await localClient.loginDeactivated(
if (!localClient!.getAccessToken()) {
await localClient!.loginDeactivated(
state.targetEmail,
state.targetPassword,
);
@@ -652,11 +678,11 @@ export function createInboundMigrationFlow() {
setError(
"Email verified! Please log in to your old account again to complete the migration.",
);
return true;
return;
}
if (state.sourceDid.startsWith("did:web:")) {
const credentials = await localClient.getRecommendedDidCredentials();
const credentials = await localClient!.getRecommendedDidCredentials();
state.targetVerificationMethod =
credentials.verificationMethods?.atproto || null;
setStep("did-web-update");
@@ -664,16 +690,11 @@ export function createInboundMigrationFlow() {
await sourceClient.requestPlcOperationSignature();
setStep("plc-token");
}
return true;
} catch (e) {
const err = e as Error & { error?: string };
if (err.error === "AccountNotVerified") {
return false;
}
return false;
} finally {
checkingEmailVerification = false;
}
},
});
function checkEmailVerifiedAndProceed(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
async function submitPlcToken(token: string): Promise<void> {
@@ -946,6 +967,10 @@ export function createInboundMigrationFlow() {
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
};
sourceClient = null;
passkeySetup = null;
@@ -1025,6 +1050,7 @@ export function createInboundMigrationFlow() {
setStep,
setError,
loadLocalServerInfo,
loadSourcePdsDomains,
resolveSourcePds,
initiateOAuthLogin,
handleOAuthCallback,
@@ -7,10 +7,9 @@ import type {
} from "./types.ts";
import {
AtprotoClient,
base64UrlEncode,
createLocalClient,
prepareWebAuthnCreationOptions,
} from "./atproto-client.ts";
import { createPasskeyCredential } from "../flows/perform-passkey-registration.ts";
import { api } from "../api.ts";
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";
@@ -124,20 +123,13 @@ export function getOfflineResumeInfo(): {
export { clearOfflineState };
function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
import {
createInitialProgress,
checkHandleAvailabilityViaClient,
loadServerInfo,
resolveVerificationIdentifier,
} from "../flows/migration-shared.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
export type OfflineInboundMigrationFlow = ReturnType<
typeof createOfflineInboundMigrationFlow
@@ -171,6 +163,10 @@ export function createOfflineInboundMigrationFlow() {
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
});
let localServerInfo: ServerDescription | null = null;
@@ -198,21 +194,13 @@ export function createOfflineInboundMigrationFlow() {
}
async function loadLocalServerInfo(): Promise<ServerDescription> {
if (!localServerInfo) {
const client = createLocalClient();
localServerInfo = await client.describeServer();
}
return localServerInfo;
const info = await loadServerInfo(createLocalClient(), localServerInfo);
localServerInfo = info;
return info;
}
async function checkHandleAvailability(handle: string): Promise<boolean> {
const client = createLocalClient();
try {
await client.resolveHandle(handle);
return false;
} catch {
return true;
}
return checkHandleAvailabilityViaClient(createLocalClient(), handle);
}
async function validateRotationKey(): Promise<boolean> {
@@ -235,18 +223,6 @@ export function createOfflineInboundMigrationFlow() {
const pdsService = lastOperation.services?.atproto_pds;
if (pdsService?.endpoint) {
state.oldPdsUrl = pdsService.endpoint;
console.log(
"[offline-migration] Captured old PDS URL:",
state.oldPdsUrl,
);
} else {
console.warn(
"[offline-migration] No PDS service endpoint found in PLC document",
);
console.log(
"[offline-migration] PLC services:",
JSON.stringify(lastOperation.services),
);
}
saveOfflineState(state);
@@ -315,9 +291,13 @@ export function createOfflineInboundMigrationFlow() {
{
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
password: state.targetPassword,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
},
);
@@ -338,8 +318,12 @@ export function createOfflineInboundMigrationFlow() {
const createResult = await api.createPasskeyAccount({
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
}, serviceAuthToken);
state.targetHandle = fullHandle;
@@ -487,20 +471,32 @@ export function createOfflineInboundMigrationFlow() {
}
async function resendEmailVerification(): Promise<void> {
await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail));
await api.resendMigrationVerification(
state.verificationChannel,
resolveVerificationIdentifier(
state.verificationChannel,
state.targetEmail,
state.discordUsername,
state.telegramUsername,
state.signalUsername,
),
);
}
let checkingEmailVerification = false;
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
if (checkingEmailVerification) return false;
if (state.authMethod === "passkey") return false;
checkingEmailVerification = true;
try {
const { verified } = await api.checkEmailVerified(state.targetEmail);
if (!verified) return false;
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (state.authMethod === "passkey") return false;
if (state.verificationChannel === "email") {
const { verified } = await api.checkEmailVerified(state.targetEmail);
return verified;
}
const { verified } = await api.checkChannelVerified(
state.userDid,
state.verificationChannel,
);
return verified;
},
async onVerified() {
if (!state.localAccessToken) {
const session = await api.createSession(
state.targetEmail,
@@ -519,12 +515,11 @@ export function createOfflineInboundMigrationFlow() {
cleanup();
setStep("success");
return true;
} catch {
return false;
} finally {
checkingEmailVerification = false;
}
},
});
function checkEmailVerifiedAndProceed(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
@@ -543,41 +538,14 @@ export function createOfflineInboundMigrationFlow() {
throw new Error("No passkey setup token");
}
if (!globalThis.PublicKeyCredential) {
throw new Error("Passkeys are not supported in this browser");
}
const { options } = await startPasskeyRegistration();
const publicKeyOptions = prepareWebAuthnCreationOptions(
options as { publicKey: Record<string, unknown> },
const credential = await createPasskeyCredential(
() => startPasskeyRegistration(),
);
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential) {
throw new Error("Passkey creation was cancelled");
}
const publicKeyCredential = credential as PublicKeyCredential;
const response = publicKeyCredential
.response as AuthenticatorAttestationResponse;
const credentialData = {
id: publicKeyCredential.id,
rawId: base64UrlEncode(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
clientDataJSON: base64UrlEncode(response.clientDataJSON),
attestationObject: base64UrlEncode(response.attestationObject),
},
};
const result = await api.completePasskeySetup(
unsafeAsDid(state.userDid),
state.passkeySetupToken,
credentialData,
credential,
passkeyName,
);
@@ -675,6 +643,10 @@ export function createOfflineInboundMigrationFlow() {
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
};
localServerInfo = null;
}
+21 -2
View File
@@ -50,6 +50,8 @@ export interface MigrationProgress {
export type HandlePreservation = "new" | "existing";
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
export interface InboundMigrationState {
direction: "inbound";
step: InboundStep;
@@ -78,6 +80,10 @@ export interface InboundMigrationState {
resumeToStep?: InboundStep;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
verificationChannel: VerificationChannel;
discordUsername: string;
telegramUsername: string;
signalUsername: string;
}
export interface OfflineInboundMigrationState {
@@ -107,6 +113,10 @@ export interface OfflineInboundMigrationState {
plcUpdatedTemporarily: boolean;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
verificationChannel: VerificationChannel;
discordUsername: string;
telegramUsername: string;
signalUsername: string;
}
export type MigrationState = InboundMigrationState;
@@ -142,6 +152,7 @@ export interface ServerDescription {
availableUserDomains: string[];
inviteCodeRequired: boolean;
phoneVerificationRequired?: boolean;
availableCommsChannels?: VerificationChannel[];
links?: {
privacyPolicy?: string;
termsOfService?: string;
@@ -226,17 +237,25 @@ export interface BlobRef {
export interface CreateAccountParams {
did?: string;
handle: string;
email: string;
email?: string;
password: string;
inviteCode?: string;
recoveryKey?: string;
verificationChannel?: VerificationChannel;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
}
export interface CreatePasskeyAccountParams {
did?: string;
handle: string;
email: string;
email?: string;
inviteCode?: string;
verificationChannel?: VerificationChannel;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
}
export interface PasskeyAccountSetup {
+11
View File
@@ -0,0 +1,11 @@
export function portal(node: HTMLElement): { destroy: () => void } {
const target = document.body;
target.appendChild(node);
return {
destroy() {
if (node.parentNode === target) {
target.removeChild(node);
}
},
};
}
@@ -1,51 +0,0 @@
<script lang="ts">
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
}
let { flow }: Props = $props()
let copied = $state(false)
let acknowledged = $state(false)
function copyToClipboard() {
if (flow.account?.appPassword) {
navigator.clipboard.writeText(flow.account.appPassword)
copied = true
}
}
</script>
<div class="app-password-step">
<div class="warning-box">
<strong>Important: Save this app password!</strong>
<p>
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
You will only see this password once.
</p>
</div>
<div class="app-password-display">
<div class="app-password-label">
App Password for: <strong>{flow.account?.appPasswordName}</strong>
</div>
<code class="app-password-code">{flow.account?.appPassword}</code>
<button type="button" class="copy-btn" onclick={copyToClipboard}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={acknowledged} />
<span>I have saved my app password in a secure location</span>
</label>
</div>
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
Continue
</button>
</div>
+104 -184
View File
@@ -1,4 +1,5 @@
import { api, ApiError } from "../api.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
import { setSession } from "../auth.svelte.ts";
import {
createServiceJwt,
@@ -37,9 +38,6 @@ export interface RegistrationFlowState {
selectedDomain: string;
handleAvailable: boolean | null;
checkingHandle: boolean;
discordInUse: boolean;
telegramInUse: boolean;
signalInUse: boolean;
}
export function createRegistrationFlow(
@@ -72,9 +70,6 @@ export function createRegistrationFlow(
selectedDomain: "",
handleAvailable: null,
checkingHandle: false,
discordInUse: false,
telegramInUse: false,
signalInUse: false,
});
function getPdsEndpoint(): string {
@@ -151,23 +146,6 @@ export function createRegistrationFlow(
}
}
async function checkCommsChannelInUse(
channel: "discord" | "telegram" | "signal",
identifier: string,
): Promise<void> {
const trimmed = identifier.trim();
if (!trimmed) {
state[`${channel}InUse`] = false;
return;
}
try {
const result = await api.checkCommsChannelInUse(channel, trimmed);
state[`${channel}InUse`] = result.inUse;
} catch {
state[`${channel}InUse`] = false;
}
}
function proceedFromInfo() {
state.error = null;
if (state.info.didType === "web-external") {
@@ -223,43 +201,51 @@ export function createRegistrationFlow(
state.step = "creating";
}
async function generateByodToken(): Promise<string | undefined> {
if (
state.info.didType !== "web-external" ||
state.externalDidWeb.keyMode !== "byod" ||
!state.externalDidWeb.byodPrivateKey
) {
return undefined;
}
return createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
function commonAccountParams() {
return {
didType: state.info.didType,
did: state.info.didType === "web-external"
? unsafeAsDid(state.info.externalDid!.trim())
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
inviteCode: state.info.inviteCode?.trim() || undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
};
}
async function createPasswordAccount() {
state.submitting = true;
state.error = null;
try {
let byodToken: string | undefined;
if (
state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "byod" &&
state.externalDidWeb.byodPrivateKey
) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
const byodToken = await generateByodToken();
const result = await api.createAccount({
handle: getFullHandle(),
email: state.info.email.trim(),
password: state.info.password!,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === "web-external"
? state.info.externalDid!.trim()
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
...commonAccountParams(),
}, byodToken);
state.account = {
@@ -280,39 +266,13 @@ export function createRegistrationFlow(
state.error = null;
try {
let byodToken: string | undefined;
if (
state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "byod" &&
state.externalDidWeb.byodPrivateKey
) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
const byodToken = await generateByodToken();
const result = await api.createPasskeyAccount({
handle: unsafeAsHandle(getFullHandle()),
email: state.info.email?.trim()
? unsafeAsEmail(state.info.email.trim())
: undefined,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === "web-external"
? unsafeAsDid(state.info.externalDid!.trim())
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
...commonAccountParams(),
}, byodToken);
state.account = {
@@ -343,6 +303,50 @@ export function createRegistrationFlow(
persistState();
}
function getAccountPassword(): string {
return state.mode === "passkey"
? state.account!.appPassword!
: state.info.password!;
}
async function handlePostVerification(
session: SessionState,
): Promise<void> {
state.session = session;
if (
state.info.didType === "web-external" &&
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 if (state.info.didType === "web-external") {
await api.activateAccount(session.accessJwt);
await finalizeSession();
state.step = "redirect-to-dashboard";
} else {
await finalizeSession();
state.step = "redirect-to-dashboard";
}
}
async function verifyAccount(code: string) {
state.submitting = true;
state.error = null;
@@ -354,48 +358,13 @@ export function createRegistrationFlow(
);
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";
}
const session = await api.createSession(
state.account!.did,
getAccountPassword(),
);
await handlePostVerification(session);
} else {
state.session = {
accessJwt: confirmResult.accessJwt,
refreshJwt: confirmResult.refreshJwt,
};
await finalizeSession();
state.step = "redirect-to-dashboard";
await handlePostVerification(confirmResult);
}
} catch (err) {
setError(err);
@@ -419,74 +388,26 @@ export function createRegistrationFlow(
}
}
let checkingVerification = false;
async function checkAndAdvanceIfVerified(): Promise<boolean> {
if (checkingVerification || !state.account) return false;
checkingVerification = true;
try {
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (!state.account) return false;
const result = await api.checkChannelVerified(
state.account.did,
state.info.verificationChannel,
);
if (!result.verified) return false;
return result.verified;
},
async onVerified() {
const session = await api.createSession(
state.account!.did,
getAccountPassword(),
);
await handlePostVerification(session);
},
});
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 checkAndAdvanceIfVerified(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
function goBack() {
@@ -554,7 +475,6 @@ export function createRegistrationFlow(
finalizeSession,
goBack,
checkHandleAvailability,
checkCommsChannelInUse,
setError(msg: string) {
state.error = msg;
+1 -1
View File
@@ -3,4 +3,4 @@ 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";
export { default as AppPasswordStep } from "./AppPasswordStep.svelte";
-39
View File
@@ -88,13 +88,6 @@ type SessionBase = {
export type Session = SessionBase & ContactState & AccountState;
export function hasEmail(
session: Session,
): session is Session & { email: EmailAddress } {
return session.contactKind === "email" ||
(session.contactKind === "channel" && session.email !== undefined);
}
export function getSessionEmail(session: Session): EmailAddress | undefined {
return session.contactKind === "email"
? session.email
@@ -103,24 +96,6 @@ export function getSessionEmail(session: Session): EmailAddress | undefined {
: undefined;
}
export function isEmailVerified(session: Session): boolean {
return session.contactKind === "email"
? session.emailConfirmed
: session.contactKind === "channel"
? session.preferredChannelVerified
: false;
}
export function isMigrated(
session: Session,
): session is Session & { accountKind: "migrated" } {
return session.accountKind === "migrated";
}
export function isDeactivated(session: Session): boolean {
return session.accountKind === "deactivated";
}
export function isActive(session: Session): boolean {
return session.accountKind === "active";
}
@@ -208,17 +183,6 @@ export interface ConfirmSignupResult {
preferredChannelVerified?: boolean;
}
export interface ListAppPasswordsResponse {
passwords: AppPassword[];
}
export interface AccountInviteCodesResponse {
codes: InviteCodeInfo[];
}
export interface CreateInviteCodeResponse {
code: InviteCodeBrand;
}
export interface ServerLinks {
privacyPolicy?: string;
@@ -317,9 +281,6 @@ export interface ListSessionsResponse {
sessions: SessionInfo[];
}
export interface RevokeAllSessionsResponse {
revokedCount: number;
}
export interface AccountSearchResult {
did: Did;
-131
View File
@@ -25,99 +25,6 @@ export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">;
export type DidKeyString = Brand<string, "DidKeyString">;
export type ScopeSet = Brand<string, "ScopeSet">;
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/;
const DID_WEB_REGEX = /^did:web:.+$/;
const HANDLE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/;
const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/;
const NSID_REGEX =
/^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ISO_DATE_REGEX =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
export function isDid(s: string): s is Did {
return s.startsWith("did:plc:") || s.startsWith("did:web:");
}
export function isDidPlc(s: string): s is DidPlc {
return DID_PLC_REGEX.test(s);
}
export function isDidWeb(s: string): s is DidWeb {
return DID_WEB_REGEX.test(s);
}
export function isHandle(s: string): s is Handle {
return HANDLE_REGEX.test(s) && s.length <= 253;
}
export function isAtUri(s: string): s is AtUri {
return AT_URI_REGEX.test(s);
}
export function isCid(s: string): s is Cid {
return CID_REGEX.test(s);
}
export function isNsid(s: string): s is Nsid {
return NSID_REGEX.test(s);
}
export function isEmail(s: string): s is EmailAddress {
return EMAIL_REGEX.test(s);
}
export function isISODate(s: string): s is ISODateString {
return ISO_DATE_REGEX.test(s);
}
export function asDid(s: string): Did {
if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`);
return s;
}
export function asDidPlc(s: string): DidPlc {
if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`);
return s as DidPlc;
}
export function asDidWeb(s: string): DidWeb {
if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`);
return s as DidWeb;
}
export function asHandle(s: string): Handle {
if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`);
return s;
}
export function asAtUri(s: string): AtUri {
if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`);
return s;
}
export function asCid(s: string): Cid {
if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`);
return s;
}
export function asNsid(s: string): Nsid {
if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`);
return s;
}
export function asEmail(s: string): EmailAddress {
if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`);
return s;
}
export function asISODate(s: string): ISODateString {
if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`);
return s;
}
export function unsafeAsDid(s: string): Did {
return s as Did;
}
@@ -134,26 +41,10 @@ export function unsafeAsRefreshToken(s: string): RefreshToken {
return s as RefreshToken;
}
export function unsafeAsServiceToken(s: string): ServiceToken {
return s as ServiceToken;
}
export function unsafeAsSetupToken(s: string): SetupToken {
return s as SetupToken;
}
export function unsafeAsCid(s: string): Cid {
return s as Cid;
}
export function unsafeAsRkey(s: string): Rkey {
return s as Rkey;
}
export function unsafeAsAtUri(s: string): AtUri {
return s as AtUri;
}
export function unsafeAsNsid(s: string): Nsid {
return s as Nsid;
}
@@ -172,29 +63,7 @@ export function unsafeAsInviteCode(s: string): InviteCode {
return s as InviteCode;
}
export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase {
return s as PublicKeyMultibase;
}
export function unsafeAsDidKey(s: string): DidKeyString {
return s as DidKeyString;
}
export function unsafeAsScopeSet(s: string): ScopeSet {
return s as ScopeSet;
}
export function parseAtUri(
uri: AtUri,
): { repo: Did; collection: Nsid; rkey: Rkey } {
const parts = uri.replace("at://", "").split("/");
return {
repo: unsafeAsDid(parts[0]),
collection: unsafeAsNsid(parts[1]),
rkey: unsafeAsRkey(parts[2]),
};
}
export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri {
return `at://${repo}/${collection}/${rkey}` as AtUri;
}
-49
View File
@@ -1,49 +0,0 @@
export function assertNever(x: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`);
}
export function exhaustive<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => void>,
): void {
const handler = handlers[value];
if (handler) {
handler();
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`);
}
}
export function exhaustiveMap<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => R>,
): R {
const handler = handlers[value];
if (handler) {
return handler();
}
return assertNever(value as never, `Unhandled case: ${String(value)}`);
}
export async function exhaustiveAsync<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => Promise<void>>,
): Promise<void> {
const handler = handlers[value];
if (handler) {
await handler();
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`);
}
}
export async function exhaustiveMapAsync<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => Promise<R>>,
): Promise<R> {
const handler = handlers[value];
if (handler) {
return handler();
}
return assertNever(value as never, `Unhandled case: ${String(value)}`);
}
-5
View File
@@ -1,5 +0,0 @@
export * from "./result.ts";
export * from "./branded.ts";
export * from "./exhaustive.ts";
export * from "./api.ts";
export * from "./routes.ts";
-85
View File
@@ -22,90 +22,5 @@ export function isErr<T, E>(
return !result.ok;
}
export function map<T, U, E>(
result: Result<T, E>,
fn: (t: T) => U,
): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
export function mapErr<T, E, F>(
result: Result<T, E>,
fn: (e: E) => F,
): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
export function flatMap<T, U, E>(
result: Result<T, E>,
fn: (t: T) => Result<U, E>,
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value;
throw result.error instanceof Error
? result.error
: new Error(String(result.error));
}
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.ok ? result.value : defaultValue;
}
export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T {
return result.ok ? result.value : fn(result.error);
}
export function match<T, E, U>(
result: Result<T, E>,
handlers: { ok: (t: T) => U; err: (e: E) => U },
): U {
return result.ok ? handlers.ok(result.value) : handlers.err(result.error);
}
export async function tryAsync<T>(
fn: () => Promise<T>,
): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function tryAsyncWith<T, E>(
fn: () => Promise<T>,
mapError: (e: unknown) => E,
): Promise<Result<T, E>> {
try {
return ok(await fn());
} catch (e) {
return err(mapError(e));
}
}
export function fromNullable<T>(value: T | null | undefined): Result<T, null> {
return value != null ? ok(value) : err(null);
}
export function toNullable<T, E>(result: Result<T, E>): T | null {
return result.ok ? result.value : null;
}
export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> {
const values: T[] = [];
for (const result of results) {
if (!result.ok) return result;
values.push(result.value);
}
return ok(values);
}
export async function collectAsync<T, E>(
results: Promise<Result<T, E>>[],
): Promise<Result<T[], E>> {
const settled = await Promise.all(results);
return collect(settled);
}
-329
View File
@@ -1,329 +0,0 @@
import { z } from "zod";
import {
unsafeAsAccessToken,
unsafeAsAtUri,
unsafeAsCid,
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
unsafeAsInviteCode,
unsafeAsISODate,
unsafeAsNsid,
unsafeAsPublicKeyMultibase,
unsafeAsRefreshToken,
unsafeAsRkey,
} from "./branded.ts";
const did = z.string().transform((s) => unsafeAsDid(s));
const handle = z.string().transform((s) => unsafeAsHandle(s));
const accessToken = z.string().transform((s) => unsafeAsAccessToken(s));
const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s));
const cid = z.string().transform((s) => unsafeAsCid(s));
const nsid = z.string().transform((s) => unsafeAsNsid(s));
const atUri = z.string().transform((s) => unsafeAsAtUri(s));
const _rkey = z.string().transform((s) => unsafeAsRkey(s));
const isoDate = z.string().transform((s) => unsafeAsISODate(s));
const email = z.string().transform((s) => unsafeAsEmail(s));
const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s));
const publicKeyMultibase = z.string().transform((s) =>
unsafeAsPublicKeyMultibase(s)
);
export const verificationChannel = z.enum([
"email",
"discord",
"telegram",
"signal",
]);
export const didType = z.enum(["plc", "web", "web-external"]);
export const accountStatus = z.enum([
"active",
"deactivated",
"migrated",
"suspended",
"deleted",
]);
export const sessionType = z.enum(["oauth", "legacy", "app_password"]);
export const reauthMethod = z.enum(["password", "totp", "passkey"]);
export const sessionSchema = z.object({
did: did,
handle: handle,
email: email.optional(),
emailConfirmed: z.boolean().optional(),
preferredChannel: verificationChannel.optional(),
preferredChannelVerified: z.boolean().optional(),
isAdmin: z.boolean().optional(),
active: z.boolean().optional(),
status: accountStatus.optional(),
migratedToPds: z.string().optional(),
migratedAt: isoDate.optional(),
accessJwt: accessToken,
refreshJwt: refreshToken,
});
export const serverLinksSchema = z.object({
privacyPolicy: z.string().optional(),
termsOfService: z.string().optional(),
});
export const serverDescriptionSchema = z.object({
availableUserDomains: z.array(z.string()),
inviteCodeRequired: z.boolean(),
links: serverLinksSchema.optional(),
version: z.string().optional(),
availableCommsChannels: z.array(verificationChannel).optional(),
selfHostedDidWebEnabled: z.boolean().optional(),
});
export const appPasswordSchema = z.object({
name: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
createdByController: z.string().optional(),
});
export const createdAppPasswordSchema = z.object({
name: z.string(),
password: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
});
export const inviteCodeUseSchema = z.object({
usedBy: did,
usedByHandle: handle.optional(),
usedAt: isoDate,
});
export const inviteCodeInfoSchema = z.object({
code: inviteCode,
available: z.number(),
disabled: z.boolean(),
forAccount: did,
createdBy: did,
createdAt: isoDate,
uses: z.array(inviteCodeUseSchema),
});
export const sessionInfoSchema = z.object({
id: z.string(),
sessionType: sessionType,
clientName: z.string().nullable(),
createdAt: isoDate,
expiresAt: isoDate,
isCurrent: z.boolean(),
});
export const listSessionsResponseSchema = z.object({
sessions: z.array(sessionInfoSchema),
});
export const totpStatusSchema = z.object({
enabled: z.boolean(),
hasBackupCodes: z.boolean(),
});
export const totpSecretSchema = z.object({
uri: z.string(),
qrBase64: z.string(),
});
export const enableTotpResponseSchema = z.object({
success: z.boolean(),
backupCodes: z.array(z.string()),
});
export const passkeyInfoSchema = z.object({
id: z.string(),
credentialId: z.string(),
friendlyName: z.string().nullable(),
createdAt: isoDate,
lastUsed: isoDate.nullable(),
});
export const listPasskeysResponseSchema = z.object({
passkeys: z.array(passkeyInfoSchema),
});
export const trustedDeviceSchema = z.object({
id: z.string(),
userAgent: z.string().nullable(),
friendlyName: z.string().nullable(),
trustedAt: isoDate.nullable(),
trustedUntil: isoDate.nullable(),
lastSeenAt: isoDate,
});
export const listTrustedDevicesResponseSchema = z.object({
devices: z.array(trustedDeviceSchema),
});
export const reauthStatusSchema = z.object({
requiresReauth: z.boolean(),
lastReauthAt: isoDate.nullable(),
availableMethods: z.array(reauthMethod),
});
export const reauthResponseSchema = z.object({
success: z.boolean(),
reauthAt: isoDate,
});
export const notificationPrefsSchema = z.object({
preferredChannel: verificationChannel,
email: email,
discordUsername: z.string().nullable(),
discordVerified: z.boolean(),
telegramUsername: z.string().nullable(),
telegramVerified: z.boolean(),
signalUsername: z.string().nullable(),
signalVerified: z.boolean(),
});
export const verificationMethodSchema = z.object({
id: z.string(),
type: z.string(),
controller: z.string(),
publicKeyMultibase: publicKeyMultibase,
});
export const serviceEndpointSchema = z.object({
id: z.string(),
type: z.string(),
serviceEndpoint: z.string(),
});
export const didDocumentSchema = z.object({
"@context": z.array(z.string()),
id: did,
alsoKnownAs: z.array(z.string()),
verificationMethod: z.array(verificationMethodSchema),
service: z.array(serviceEndpointSchema),
});
export const repoDescriptionSchema = z.object({
handle: handle,
did: did,
didDoc: didDocumentSchema,
collections: z.array(nsid),
handleIsCorrect: z.boolean(),
});
export const recordInfoSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
});
export const listRecordsResponseSchema = z.object({
records: z.array(recordInfoSchema),
cursor: z.string().optional(),
});
export const recordResponseSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
});
export const createRecordResponseSchema = z.object({
uri: atUri,
cid: cid,
});
export const serverStatsSchema = z.object({
userCount: z.number(),
repoCount: z.number(),
recordCount: z.number(),
blobStorageBytes: z.number(),
});
export const serverConfigSchema = z.object({
serverName: z.string(),
primaryColor: z.string().nullable(),
primaryColorDark: z.string().nullable(),
secondaryColor: z.string().nullable(),
secondaryColorDark: z.string().nullable(),
logoCid: cid.nullable(),
});
export const passwordStatusSchema = z.object({
hasPassword: z.boolean(),
});
export const successResponseSchema = z.object({
success: z.boolean(),
});
export const legacyLoginPreferenceSchema = z.object({
allowLegacyLogin: z.boolean(),
hasMfa: z.boolean(),
});
export const accountInfoSchema = z.object({
did: did,
handle: handle,
email: email.optional(),
indexedAt: isoDate,
emailConfirmedAt: isoDate.optional(),
invitesDisabled: z.boolean().optional(),
deactivatedAt: isoDate.optional(),
});
export const searchAccountsResponseSchema = z.object({
cursor: z.string().optional(),
accounts: z.array(accountInfoSchema),
});
export type ValidatedSession = z.infer<typeof sessionSchema>;
export type ValidatedServerDescription = z.infer<
typeof serverDescriptionSchema
>;
export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>;
export type ValidatedCreatedAppPassword = z.infer<
typeof createdAppPasswordSchema
>;
export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>;
export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>;
export type ValidatedListSessionsResponse = z.infer<
typeof listSessionsResponseSchema
>;
export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>;
export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>;
export type ValidatedEnableTotpResponse = z.infer<
typeof enableTotpResponseSchema
>;
export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>;
export type ValidatedListPasskeysResponse = z.infer<
typeof listPasskeysResponseSchema
>;
export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>;
export type ValidatedListTrustedDevicesResponse = z.infer<
typeof listTrustedDevicesResponseSchema
>;
export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>;
export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>;
export type ValidatedNotificationPrefs = z.infer<
typeof notificationPrefsSchema
>;
export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>;
export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>;
export type ValidatedListRecordsResponse = z.infer<
typeof listRecordsResponseSchema
>;
export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>;
export type ValidatedCreateRecordResponse = z.infer<
typeof createRecordResponseSchema
>;
export type ValidatedServerStats = z.infer<typeof serverStatsSchema>;
export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>;
export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>;
export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>;
export type ValidatedLegacyLoginPreference = z.infer<
typeof legacyLoginPreferenceSchema
>;
export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>;
export type ValidatedSearchAccountsResponse = z.infer<
typeof searchAccountsResponseSchema
>;
-12
View File
@@ -61,18 +61,6 @@ export function finish(_state: TotpBackup): TotpIdle {
return idleState;
}
export function isIdle(state: TotpSetupState): state is TotpIdle {
return state.step === "idle";
}
export function isQr(state: TotpSetupState): state is TotpQr {
return state.step === "qr";
}
export function isVerify(state: TotpSetupState): state is TotpVerify {
return state.step === "verify";
}
export function isBackup(state: TotpSetupState): state is TotpBackup {
return state.step === "backup";
}
-205
View File
@@ -1,205 +0,0 @@
import type { Option } from "./option.ts";
export function first<T>(arr: readonly T[]): Option<T> {
return arr[0] ?? null;
}
export function last<T>(arr: readonly T[]): Option<T> {
return arr[arr.length - 1] ?? null;
}
export function at<T>(arr: readonly T[], index: number): Option<T> {
if (index < 0) index = arr.length + index;
return arr[index] ?? null;
}
export function find<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<T> {
return arr.find(predicate) ?? null;
}
export function findMap<T, U>(
arr: readonly T[],
fn: (t: T) => Option<U>,
): Option<U> {
for (const item of arr) {
const result = fn(item);
if (result != null) return result;
}
return null;
}
export function findIndex<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<number> {
const index = arr.findIndex(predicate);
return index >= 0 ? index : null;
}
export function partition<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): [T[], T[]] {
const pass: T[] = [];
const fail: T[] = [];
for (const item of arr) {
if (predicate(item)) {
pass.push(item);
} else {
fail.push(item);
}
}
return [pass, fail];
}
export function groupBy<T, K extends string | number>(
arr: readonly T[],
keyFn: (t: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of arr) {
const key = keyFn(item);
if (!result[key]) {
result[key] = [];
}
result[key].push(item);
}
return result;
}
export function unique<T>(arr: readonly T[]): T[] {
return [...new Set(arr)];
}
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
const seen = new Set<K>();
const result: T[] = [];
for (const item of arr) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.add(key);
result.push(item);
}
}
return result;
}
export function sortBy<T>(
arr: readonly T[],
keyFn: (t: T) => number | string,
): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a);
const kb = keyFn(b);
if (ka < kb) return -1;
if (ka > kb) return 1;
return 0;
});
}
export function sortByDesc<T>(
arr: readonly T[],
keyFn: (t: T) => number | string,
): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a);
const kb = keyFn(b);
if (ka > kb) return -1;
if (ka < kb) return 1;
return 0;
});
}
export function chunk<T>(arr: readonly T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] {
const length = Math.min(a.length, b.length);
const result: [T, U][] = [];
for (let i = 0; i < length; i++) {
result.push([a[i], b[i]]);
}
return result;
}
export function zipWith<T, U, R>(
a: readonly T[],
b: readonly U[],
fn: (t: T, u: U) => R,
): R[] {
const length = Math.min(a.length, b.length);
const result: R[] = [];
for (let i = 0; i < length; i++) {
result.push(fn(a[i], b[i]));
}
return result;
}
export function intersperse<T>(arr: readonly T[], separator: T): T[] {
if (arr.length <= 1) return [...arr];
const result: T[] = [arr[0]];
for (let i = 1; i < arr.length; i++) {
result.push(separator, arr[i]);
}
return result;
}
export function range(start: number, end: number): number[] {
const result: number[] = [];
for (let i = start; i < end; i++) {
result.push(i);
}
return result;
}
export function isEmpty<T>(arr: readonly T[]): boolean {
return arr.length === 0;
}
export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] {
return arr.length > 0;
}
export function sum(arr: readonly number[]): number {
return arr.reduce((acc, n) => acc + n, 0);
}
export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number {
return arr.reduce((acc, t) => acc + fn(t), 0);
}
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null;
let max = arr[0];
let maxValue = fn(max);
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i]);
if (value > maxValue) {
max = arr[i];
maxValue = value;
}
}
return max;
}
export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null;
let min = arr[0];
let minValue = fn(min);
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i]);
if (value < minValue) {
min = arr[i];
minValue = value;
}
}
return min;
}
-245
View File
@@ -1,245 +0,0 @@
import { err, type Result } from "../types/result.ts";
export function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number,
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, ms);
}) as T & { cancel: () => void };
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debounced;
}
export function throttle<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number,
): T {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
const now = Date.now();
const remaining = ms - (now - lastCall);
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
fn(...args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn(...args);
}, remaining);
}
}) as T;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function retry<T>(
fn: () => Promise<T>,
options: {
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
} = {},
): Promise<T> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options;
let lastError: unknown;
let currentDelay = delay;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === attempts || !shouldRetry(error, attempt)) {
throw error;
}
await sleep(currentDelay);
currentDelay *= backoff;
}
}
throw lastError;
}
export async function retryResult<T, E>(
fn: () => Promise<Result<T, E>>,
options: {
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: E, attempt: number) => boolean;
} = {},
): Promise<Result<T, E>> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options;
let lastResult: Result<T, E> | null = null;
let currentDelay = delay;
for (let attempt = 1; attempt <= attempts; attempt++) {
const result = await fn();
lastResult = result;
if (result.ok) {
return result;
}
if (attempt === attempts || !shouldRetry(result.error, attempt)) {
return result;
}
await sleep(currentDelay);
currentDelay *= backoff;
}
return lastResult!;
}
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms`));
}, ms);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
export async function timeoutResult<T>(
promise: Promise<Result<T, Error>>,
ms: number,
): Promise<Result<T, Error>> {
try {
return await timeout(promise, ms);
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function parallel<T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<T[]> {
const results: T[] = [];
const executing: Promise<void>[] = [];
for (const task of tasks) {
const p = task().then((result) => {
results.push(result);
});
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing);
executing.splice(
executing.findIndex((e) => e === p),
1,
);
}
}
await Promise.all(executing);
return results;
}
export async function mapParallel<T, U>(
items: T[],
fn: (item: T, index: number) => Promise<U>,
concurrency: number,
): Promise<U[]> {
const results: U[] = new Array(items.length);
const executing: Promise<void>[] = [];
for (let i = 0; i < items.length; i++) {
const index = i;
const p = fn(items[index], index).then((result) => {
results[index] = result;
});
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing);
const doneIndex = executing.findIndex(
(e) => (e as Promise<void> & { _done?: boolean })._done !== false,
);
if (doneIndex >= 0) {
executing.splice(doneIndex, 1);
}
}
}
await Promise.all(executing);
return results;
}
export function createAbortable<T>(
fn: (signal: AbortSignal) => Promise<T>,
): { promise: Promise<T>; abort: () => void } {
const controller = new AbortController();
return {
promise: fn(controller.signal),
abort: () => controller.abort(),
};
}
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
-27
View File
@@ -1,27 +0,0 @@
export * from "./option.ts";
export {
at,
chunk,
find,
findIndex,
findMap,
first,
groupBy,
intersperse,
isEmpty,
isNonEmpty,
last,
maxBy,
minBy,
partition,
range,
sortBy,
sortByDesc,
sum,
sumBy,
unique,
uniqueBy,
zip as zipArrays,
zipWith as zipArraysWith,
} from "./array.ts";
export * from "./async.ts";
-85
View File
@@ -1,85 +0,0 @@
export type Option<T> = T | null | undefined;
export function isSome<T>(opt: Option<T>): opt is T {
return opt != null;
}
export function isNone<T>(opt: Option<T>): opt is null | undefined {
return opt == null;
}
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
return isSome(opt) ? fn(opt) : null;
}
export function flatMap<T, U>(
opt: Option<T>,
fn: (t: T) => Option<U>,
): Option<U> {
return isSome(opt) ? fn(opt) : null;
}
export function filter<T>(
opt: Option<T>,
predicate: (t: T) => boolean,
): Option<T> {
return isSome(opt) && predicate(opt) ? opt : null;
}
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return isSome(opt) ? opt : defaultValue;
}
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
return isSome(opt) ? opt : fn();
}
export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T {
if (isSome(opt)) return opt;
if (error instanceof Error) throw error;
throw new Error(error ?? "Expected value but got null/undefined");
}
export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
if (isSome(opt)) fn(opt);
return opt;
}
export function match<T, U>(
opt: Option<T>,
handlers: { some: (t: T) => U; none: () => U },
): U {
return isSome(opt) ? handlers.some(opt) : handlers.none();
}
export function toArray<T>(opt: Option<T>): T[] {
return isSome(opt) ? [opt] : [];
}
export function fromArray<T>(arr: T[]): Option<T> {
return arr.length > 0 ? arr[0] : null;
}
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
return isSome(a) && isSome(b) ? [a, b] : null;
}
export function zipWith<T, U, R>(
a: Option<T>,
b: Option<U>,
fn: (t: T, u: U) => R,
): Option<R> {
return isSome(a) && isSome(b) ? fn(a, b) : null;
}
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
return isSome(a) ? a : b;
}
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
return isSome(a) ? a : fn();
}
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
return isSome(a) ? b : null;
}
-286
View File
@@ -1,286 +0,0 @@
import { err, ok, type Result } from "./types/result.ts";
import {
type AtUri,
type Cid,
type Did,
type DidPlc,
type DidWeb,
type EmailAddress,
type Handle,
isAtUri,
isCid,
isDid,
isDidPlc,
isDidWeb,
isEmail,
isHandle,
isISODate,
isNsid,
type ISODateString,
type Nsid,
} from "./types/branded.ts";
export class ValidationError extends Error {
constructor(
message: string,
public readonly field?: string,
public readonly value?: unknown,
) {
super(message);
this.name = "ValidationError";
}
}
export function parseDid(s: string): Result<Did, ValidationError> {
if (isDid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID: ${s}`, "did", s));
}
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
if (isDidPlc(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s));
}
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
if (isDidWeb(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s));
}
export function parseHandle(s: string): Result<Handle, ValidationError> {
const trimmed = s.trim().toLowerCase();
if (isHandle(trimmed)) {
return ok(trimmed);
}
return err(new ValidationError(`Invalid handle: ${s}`, "handle", s));
}
export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
const trimmed = s.trim().toLowerCase();
if (isEmail(trimmed)) {
return ok(trimmed);
}
return err(new ValidationError(`Invalid email: ${s}`, "email", s));
}
export function parseAtUri(s: string): Result<AtUri, ValidationError> {
if (isAtUri(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s));
}
export function parseCid(s: string): Result<Cid, ValidationError> {
if (isCid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid CID: ${s}`, "cid", s));
}
export function parseNsid(s: string): Result<Nsid, ValidationError> {
if (isNsid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s));
}
export function parseISODate(
s: string,
): Result<ISODateString, ValidationError> {
if (isISODate(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s));
}
export interface PasswordValidationResult {
valid: boolean;
errors: string[];
strength: "weak" | "fair" | "good" | "strong";
}
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Password must be at least 8 characters");
}
if (password.length > 256) {
errors.push("Password must be at most 256 characters");
}
if (!/[a-z]/.test(password)) {
errors.push("Password must contain a lowercase letter");
}
if (!/[A-Z]/.test(password)) {
errors.push("Password must contain an uppercase letter");
}
if (!/\d/.test(password)) {
errors.push("Password must contain a number");
}
let strength: PasswordValidationResult["strength"] = "weak";
if (errors.length === 0) {
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
const isLong = password.length >= 12;
const isVeryLong = password.length >= 16;
if (isVeryLong && hasSpecial) {
strength = "strong";
} else if (isLong || hasSpecial) {
strength = "good";
} else {
strength = "fair";
}
}
return {
valid: errors.length === 0,
errors,
strength,
};
}
export function validateHandle(
handle: string,
): Result<Handle, ValidationError> {
const trimmed = handle.trim().toLowerCase();
if (trimmed.length < 3) {
return err(
new ValidationError(
"Handle must be at least 3 characters",
"handle",
handle,
),
);
}
if (trimmed.length > 253) {
return err(
new ValidationError(
"Handle must be at most 253 characters",
"handle",
handle,
),
);
}
if (!isHandle(trimmed)) {
return err(new ValidationError("Invalid handle format", "handle", handle));
}
return ok(trimmed);
}
export function validateInviteCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim();
if (trimmed.length === 0) {
return err(
new ValidationError("Invite code is required", "inviteCode", code),
);
}
const pattern = /^[a-zA-Z0-9-]+$/;
if (!pattern.test(trimmed)) {
return err(
new ValidationError("Invalid invite code format", "inviteCode", code),
);
}
return ok(trimmed);
}
export function validateTotpCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, "");
if (!/^\d{6}$/.test(trimmed)) {
return err(new ValidationError("TOTP code must be 6 digits", "code", code));
}
return ok(trimmed);
}
export function validateBackupCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, "").toLowerCase();
if (!/^[a-z0-9]{8}$/.test(trimmed)) {
return err(new ValidationError("Invalid backup code format", "code", code));
}
return ok(trimmed);
}
export interface FormValidation<T> {
validate: () => Result<T, ValidationError[]>;
field: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
optional: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
}
export function createFormValidation<T extends Record<string, unknown>>(
data: Record<string, unknown>,
): FormValidation<T> {
const validators: Array<{
key: string;
validator: (value: unknown) => Result<unknown, ValidationError>;
optional: boolean;
}> = [];
const builder: FormValidation<T> = {
field: (key, validator) => {
validators.push({ key: key as string, validator, optional: false });
return builder;
},
optional: (key, validator) => {
validators.push({ key: key as string, validator, optional: true });
return builder;
},
validate: () => {
const errors: ValidationError[] = [];
const result: Record<string, unknown> = {};
for (const { key, validator, optional } of validators) {
const value = data[key];
if (value == null || value === "") {
if (!optional) {
errors.push(new ValidationError(`${key} is required`, key));
}
continue;
}
const validated = validator(value);
if (validated.ok) {
result[key] = validated.value;
} else {
errors.push(validated.error);
}
}
if (errors.length > 0) {
return err(errors);
}
return ok(result as T);
},
};
return builder;
}
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord Username",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "Discord username in use by another account",
"telegram": "Telegram",
"telegramUsername": "Telegram Username",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "Telegram username in use by another account",
"signal": "Signal",
"signalUsername": "Signal Username",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "Signal username in use by another account",
"notConfigured": "not configured",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter your invite code",
@@ -412,9 +409,6 @@
"verifiedSuccess": "{channel} verified successfully",
"messageHistory": "Message History",
"noMessages": "No messages found.",
"discordInUseWarning": "This Discord username is already associated with another account.",
"telegramInUseWarning": "This Telegram username is already associated with another account.",
"signalInUseWarning": "This Signal username is already associated with another account.",
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
"telegramOpenLink": "Open Telegram to verify",
"discordStartBot": "DM @{botUsername} on Discord and send /start {handle}",
@@ -1027,12 +1021,17 @@
"continue": "Continue"
},
"emailVerify": {
"title": "Verify Your Email",
"title": "Verify Your Account",
"desc": "A verification code has been sent to {email}.",
"hint": "Enter the code below, or click the link in the email to continue automatically.",
"hint": "Enter the code below, or click the link in the message to continue automatically.",
"tokenLabel": "Verification Code",
"tokenPlaceholder": "Enter code from email",
"resend": "Resend Code"
"tokenPlaceholder": "Enter verification code",
"resend": "Resend Code",
"telegramInstructions": "Message the Telegram bot to verify your account.",
"discordInstructions": "Message the Discord bot to verify your account.",
"openTelegram": "Open Telegram to verify",
"openDiscord": "Open Discord to verify",
"waitingForVerification": "Waiting for verification..."
},
"plcToken": {
"title": "Verify Migration",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord-käyttäjänimi",
"discordUsernamePlaceholder": "käyttäjänimesi",
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegram": "Telegram",
"telegramUsername": "Telegram-käyttäjänimi",
"telegramUsernamePlaceholder": "@käyttäjänimesi",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signal": "Signal",
"signalUsername": "Signal-käyttäjänimi",
"signalUsernamePlaceholder": "käyttäjänimi.01",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"notConfigured": "ei määritetty",
"inviteCode": "Kutsukoodi",
"inviteCodePlaceholder": "Syötä kutsukoodisi",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} vahvistettu",
"messageHistory": "Viestihistoria",
"noMessages": "Viestejä ei löytynyt.",
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
"discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa",
@@ -1026,12 +1020,17 @@
"continue": "Jatka"
},
"emailVerify": {
"title": "Vahvista sähköpostisi",
"title": "Vahvista tilisi",
"desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.",
"hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.",
"hint": "Syötä koodi alle tai klikkaa viestissä olevaa linkkiä jatkaaksesi automaattisesti.",
"tokenLabel": "Vahvistuskoodi",
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
"resend": "Lähetä koodi uudelleen"
"tokenPlaceholder": "Syötä vahvistuskoodi",
"resend": "Lähetä koodi uudelleen",
"telegramInstructions": "Lähetä viesti Telegram-botille vahvistaaksesi tilisi.",
"discordInstructions": "Lähetä viesti Discord-botille vahvistaaksesi tilisi.",
"openTelegram": "Avaa Telegram vahvistaaksesi",
"openDiscord": "Avaa Discord vahvistaaksesi",
"waitingForVerification": "Odotetaan vahvistusta..."
},
"plcToken": {
"title": "Vahvista siirto",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord ユーザー名",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegram": "Telegram",
"telegramUsername": "Telegram ユーザー名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signal": "Signal",
"signalUsername": "Signal ユーザー名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"notConfigured": "未設定",
"inviteCode": "招待コード",
"inviteCodePlaceholder": "招待コードを入力",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} を確認しました",
"messageHistory": "メッセージ履歴",
"noMessages": "メッセージが見つかりません。",
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
"telegramOpenLink": "Telegram で確認する",
"discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信",
@@ -1026,12 +1020,17 @@
"continue": "続ける"
},
"emailVerify": {
"title": "メールアドレスを確認",
"title": "アカウントを確認",
"desc": "確認コードが {email} に送信されました。",
"hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。",
"hint": "下記にコードを入力するか、メッセージ内のリンクをクリックして自動的に続行できます。",
"tokenLabel": "確認コード",
"tokenPlaceholder": "メールに記載されたコードを入力",
"resend": "コードを再送信"
"tokenPlaceholder": "確認コードを入力",
"resend": "コードを再送信",
"telegramInstructions": "Telegram ボットにメッセージを送信してアカウントを確認してください。",
"discordInstructions": "Discord ボットにメッセージを送信してアカウントを確認してください。",
"openTelegram": "Telegram で確認する",
"openDiscord": "Discord で確認する",
"waitingForVerification": "確認を待っています..."
},
"plcToken": {
"title": "移行を確認",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord 사용자명",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegram": "Telegram",
"telegramUsername": "Telegram 사용자 이름",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signal": "Signal",
"signalUsername": "Signal 사용자명",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"notConfigured": "구성되지 않음",
"inviteCode": "초대 코드",
"inviteCodePlaceholder": "초대 코드 입력",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} 인증 완료",
"messageHistory": "메시지 기록",
"noMessages": "메시지가 없습니다.",
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
"telegramOpenLink": "Telegram에서 인증하기",
"discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기",
@@ -1026,12 +1020,17 @@
"continue": "계속"
},
"emailVerify": {
"title": "이메일 인증",
"title": "계정 인증",
"desc": "인증 코드가 {email}(으)로 전송되었습니다.",
"hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
"hint": "아래에 코드를 입력하거나, 메시지의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
"tokenLabel": "인증 코드",
"tokenPlaceholder": "이메일에서 받은 코드 입력",
"resend": "코드 재전송"
"tokenPlaceholder": "인증 코드 입력",
"resend": "코드 재전송",
"telegramInstructions": "Telegram 봇에 메시지를 보내 계정을 인증하세요.",
"discordInstructions": "Discord 봇에 메시지를 보내 계정을 인증하세요.",
"openTelegram": "Telegram에서 인증하기",
"openDiscord": "Discord에서 인증하기",
"waitingForVerification": "인증 대기 중..."
},
"plcToken": {
"title": "마이그레이션 확인",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord-användarnamn",
"discordUsernamePlaceholder": "dittanvändarnamn",
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
"telegram": "Telegram",
"telegramUsername": "Telegram-användarnamn",
"telegramUsernamePlaceholder": "@dittanvändarnamn",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signal": "Signal",
"signalUsername": "Signal-användarnamn",
"signalUsernamePlaceholder": "användarnamn.01",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"notConfigured": "ej konfigurerad",
"inviteCode": "Inbjudningskod",
"inviteCodePlaceholder": "Ange din inbjudningskod",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} verifierad",
"messageHistory": "Meddelandehistorik",
"noMessages": "Inga meddelanden hittades.",
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
"telegramOpenLink": "Öppna Telegram för att verifiera",
"discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}",
@@ -1026,12 +1020,17 @@
"continue": "Fortsätt"
},
"emailVerify": {
"title": "Verifiera din e-post",
"title": "Verifiera ditt konto",
"desc": "En verifieringskod har skickats till {email}.",
"hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.",
"hint": "Ange koden nedan eller klicka på länken i meddelandet för att fortsätta automatiskt.",
"tokenLabel": "Verifieringskod",
"tokenPlaceholder": "Ange kod från e-post",
"resend": "Skicka kod igen"
"tokenPlaceholder": "Ange verifieringskod",
"resend": "Skicka kod igen",
"telegramInstructions": "Skicka ett meddelande till Telegram-boten för att verifiera ditt konto.",
"discordInstructions": "Skicka ett meddelande till Discord-boten för att verifiera ditt konto.",
"openTelegram": "Öppna Telegram för att verifiera",
"openDiscord": "Öppna Discord för att verifiera",
"waitingForVerification": "Väntar på verifiering..."
},
"plcToken": {
"title": "Verifiera flytt",
+9 -10
View File
@@ -86,15 +86,12 @@
"discord": "Discord",
"discordUsername": "Discord 用户名",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
"telegram": "Telegram",
"telegramUsername": "Telegram 用户名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signal": "Signal",
"signalUsername": "Signal 用户名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "此 Signal 用户名已被其他账户使用。",
"notConfigured": "未配置",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入您的邀请码",
@@ -408,9 +405,6 @@
"verifiedSuccess": "{channel} 验证成功",
"messageHistory": "消息历史",
"noMessages": "暂无消息记录",
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signalInUseWarning": "此 Signal 用户名已与另一个账户关联。",
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
"telegramOpenLink": "打开 Telegram 验证",
"discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}",
@@ -1026,12 +1020,17 @@
"continue": "继续"
},
"emailVerify": {
"title": "验证您的邮箱",
"title": "验证您的账户",
"desc": "验证码已发送至 {email}。",
"hint": "在下方输入验证码,或点击邮件中的链接自动继续。",
"hint": "在下方输入验证码,或点击消息中的链接自动继续。",
"tokenLabel": "验证码",
"tokenPlaceholder": "输入邮件中的验证码",
"resend": "重新发送"
"tokenPlaceholder": "输入验证码",
"resend": "重新发送",
"telegramInstructions": "向 Telegram 机器人发送消息以验证您的账户。",
"discordInstructions": "向 Discord 机器人发送消息以验证您的账户。",
"openTelegram": "打开 Telegram 验证",
"openDiscord": "打开 Discord 验证",
"waitingForVerification": "等待验证..."
},
"plcToken": {
"title": "验证迁移",
-112
View File
@@ -1,112 +0,0 @@
<script lang="ts">
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
let code = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
function getChannel(): string {
const params = new URLSearchParams(window.location.search)
return params.get('channel') || 'email'
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = $_('oauth.twoFactorCode.errors.missingRequestUri')
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
code: code.trim()
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || $_('oauth.twoFactorCode.errors.verificationFailed')
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = $_('oauth.twoFactorCode.errors.unexpectedResponse')
submitting = false
} catch {
error = $_('oauth.twoFactorCode.errors.connectionFailed')
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
}
let channel = $derived(getChannel())
</script>
<div class="oauth-2fa-container">
<h1>{$_('oauth.twoFactorCode.title')}</h1>
<p class="subtitle">
{$_('oauth.twoFactorCode.subtitle', { values: { channel } })}
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div>
<label for="code">{$_('oauth.twoFactorCode.codeLabel')}</label>
<input
id="code"
type="text"
bind:value={code}
placeholder={$_('oauth.twoFactorCode.codePlaceholder')}
disabled={submitting}
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="cancel" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || code.trim().length !== 6}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
+18 -6
View File
@@ -13,13 +13,12 @@
let submitting = $state(false)
let accounts = $state<AccountInfo[]>([])
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
function getParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name)
}
async function fetchAccounts() {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (!requestUri) {
error = 'Missing request_uri parameter'
loading = false
@@ -36,6 +35,19 @@
}
const data = await response.json()
accounts = data.accounts || []
const loginHint = getParam('login_hint')
if (loginHint && accounts.length > 0) {
const hint = loginHint.toLowerCase()
const matched = accounts.find(
(a) => a.did === hint || a.handle.toLowerCase() === hint
)
if (matched) {
loading = false
handleSelectAccount(matched.did)
return
}
}
} catch {
error = 'Failed to connect to server'
} finally {
@@ -44,7 +56,7 @@
}
async function handleSelectAccount(did: string) {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (!requestUri) {
error = 'Missing request_uri parameter'
return
@@ -98,7 +110,7 @@
}
function handleDifferentAccount() {
const requestUri = getRequestUri()
const requestUri = getParam('request_uri')
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
+8 -8
View File
@@ -310,15 +310,15 @@
<div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div>
<div class="delegation-info">
<div class="info-row">
<span class="label">{$_('oauthConsent.actingAs')}</span>
<span class="did">{consentData.did}</span>
<span class="consent-account-label">{$_('oauthConsent.actingAs')}</span>
<span class="consent-account-did">{consentData.did}</span>
</div>
<div class="info-row">
<span class="label">{$_('oauthConsent.controller')}</span>
<span class="handle">@{consentData.controller_handle || consentData.controller_did}</span>
<span class="consent-account-label">{$_('oauthConsent.controller')}</span>
<span class="consent-account-handle">@{consentData.controller_handle || consentData.controller_did}</span>
</div>
<div class="info-row">
<span class="label">{$_('oauthConsent.accessLevel')}</span>
<span class="consent-account-label">{$_('oauthConsent.accessLevel')}</span>
<span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span>
</div>
</div>
@@ -340,11 +340,11 @@
</div>
{/if}
{:else}
<span class="label">{$_('oauth.consent.signingInAs')}</span>
<span class="consent-account-label">{$_('oauth.consent.signingInAs')}</span>
{#if consentData.handle}
<span class="handle">@{consentData.handle}</span>
<span class="consent-account-handle">@{consentData.handle}</span>
{/if}
<span class="did">{consentData.did}</span>
<span class="consent-account-did">{consentData.did}</span>
{/if}
</div>
</div>
+11 -15
View File
@@ -494,18 +494,17 @@
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
</div>
{/if}
</div>
<div class="cancel-row">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
</div>
{:else}
{#if hasPassword || !securityStatusChecked}
<div>
@@ -526,17 +525,14 @@
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="cancel-row">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
</div>
{/if}
</form>
-585
View File
@@ -1,585 +0,0 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api } 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'
import HandleInput from '../components/HandleInput.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('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
fetchClientName()
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
}
}
async function fetchClientName() {
const requestUri = getRequestUri()
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
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
completeOAuthRegistration()
}
})
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)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
} 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.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
break
case 'signal':
if (!info.signalUsername?.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 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 Error) {
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
} else {
flow.setError($_('registerPasskey.errors.passkeyFailed'))
}
} finally {
flow.setSubmitting(false)
}
}
async function completeOAuthRegistration() {
const requestUri = getRequestUri()
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 {
flow.setError($_('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()
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
})
async function handleCancel() {
const requestUri = getRequestUri()
if (!requestUri) {
window.history.back()
return
}
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 goToLogin() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
navigate(routes.login)
}
}
</script>
<div class="oauth-register-container">
{#if loadingServerInfo}
<div class="loading"></div>
{:else if flow}
<header class="page-header">
<h1>{$_('oauth.register.title')}</h1>
<p class="subtitle">
{#if clientName}
{$_('oauth.register.subtitle')} <strong>{clientName}</strong>
{:else}
{$_('oauth.register.subtitleGeneric')}
{/if}
</p>
</header>
{#if flow.state.error}
<div class="error">{flow.state.error}</div>
{/if}
{#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} oauthRequestUri={getRequestUri()} />
<div class="split-layout">
<div class="form-section">
<form onsubmit={handleInfoSubmit}>
<div>
<label for="handle">{$_('register.handle')}</label>
<HandleInput
value={flow.info.handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
/>
{#if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<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">{channelLabel('email')}</option>
{#if isChannelAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isChannelAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isChannelAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div 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.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</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={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 ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div>
<label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label>
<input
id="invite-code"
type="text"
bind:value={flow.info.inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{/if}
<div class="actions">
<button type="submit" class="primary" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
</button>
</div>
<div class="secondary-actions">
<button type="button" class="link" onclick={goToLogin}>
{$_('oauth.register.haveAccount')}
</button>
<button type="button" class="link" onclick={handleCancel}>
{$_('common.cancel')}
</button>
</div>
</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={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
{:else if flow.state.step === 'creating'}
<div class="creating">
<p>{$_('registerPasskey.creatingAccount')}</p>
</div>
{:else if flow.state.step === 'passkey'}
<div class="passkey-step">
<h2>{$_('registerPasskey.setupPasskey')}</h2>
<p>{$_('registerPasskey.passkeyDescription')}</p>
<div class="field">
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
<input
id="passkey-name"
type="text"
bind:value={passkeyName}
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
disabled={flow.state.submitting}
/>
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<button
type="button"
class="primary"
onclick={handlePasskeyRegistration}
disabled={flow.state.submitting}
>
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')}
</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 === 'activating'}
<div class="creating">
<p>{$_('registerPasskey.activatingAccount')}</p>
</div>
{/if}
{/if}
</div>
-508
View File
@@ -1,508 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
interface PendingRegistration {
request_uri: string
provider: string
provider_user_id: string
provider_username: string | null
provider_email: string | null
provider_email_verified: boolean
}
interface CommsChannelConfig {
email: boolean
discord: boolean
telegram: boolean
signal: boolean
}
let pending = $state<PendingRegistration | null>(null)
let loading = $state(true)
let submitting = $state(false)
let error = $state<string | null>(null)
let handle = $state('')
let email = $state('')
let providerEmailOriginal = $state<string | null>(null)
let inviteCode = $state('')
let verificationChannel = $state('email')
let discordUsername = $state('')
let telegramUsername = $state('')
let signalUsername = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let selectedDomain = $state('')
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>({
email: true,
discord: false,
telegram: false,
signal: false,
})
function getToken(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('token')
}
function getProviderDisplayName(provider: string): string {
const names: Record<string, string> = {
github: 'GitHub',
discord: 'Discord',
google: 'Google',
gitlab: 'GitLab',
oidc: 'SSO',
}
return names[provider] || provider
}
function isChannelAvailable(ch: string): boolean {
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 ''
if (handle.includes('.')) return handle.trim()
return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim()
})
onMount(() => {
loadPendingRegistration()
loadServerInfo()
})
async function loadServerInfo() {
try {
const response = await fetch('/xrpc/com.atproto.server.describeServer')
if (response.ok) {
const data = await response.json()
serverInfo = {
availableUserDomains: data.availableUserDomains || [],
inviteCodeRequired: data.inviteCodeRequired ?? false,
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
}
const available: string[] = data.availableCommsChannels ?? ['email']
commsChannels = {
email: available.includes('email'),
discord: available.includes('discord'),
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
selectedDomain = data.availableUserDomains?.[0] || window.location.hostname
}
} catch {
serverInfo = null
}
}
async function loadPendingRegistration() {
const token = getToken()
if (!token) {
error = $_('sso_register.error_expired')
loading = false
return
}
try {
const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
if (!response.ok) {
const data = await response.json()
error = data.message || $_('sso_register.error_expired')
loading = false
return
}
pending = await response.json()
if (pending?.provider_email) {
email = pending.provider_email
providerEmailOriginal = pending.provider_email
}
if (pending?.provider_username) {
handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
}
} catch {
error = $_('sso_register.error_expired')
} finally {
loading = false
}
}
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void selectedDomain
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
handleAvailable = null
handleError = null
if (handle.length >= 3) {
checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
}
})
async function checkHandleAvailability() {
if (!handle || handle.length < 3) return
checkingHandle = true
handleError = null
try {
const params = new URLSearchParams({ handle })
if (selectedDomain) params.set('domain', selectedDomain)
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
const data = await response.json()
handleAvailable = data.available
if (!data.available && data.reason) {
handleError = data.reason
}
} catch {
handleAvailable = null
handleError = $_('common.error')
} finally {
checkingHandle = false
}
}
let usingVerifiedProviderEmail = $derived(
pending?.provider_email_verified &&
verificationChannel === 'email' &&
email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
)
function isChannelValid(): boolean {
switch (verificationChannel) {
case 'email':
return !!email.trim()
case 'discord':
return !!discordUsername.trim()
case 'telegram':
return !!telegramUsername.trim()
case 'signal':
return !!signalUsername.trim()
default:
return false
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const token = getToken()
if (!token || !pending) return
if (!handle || handle.length < 3) {
handleError = $_('sso_register.error_handle_required')
return
}
if (handleAvailable === false) {
handleError = $_('sso_register.handle_taken')
return
}
if (!isChannelValid()) {
toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
return
}
const fullHandle = !handle.includes('.') && selectedDomain
? `${handle.trim()}.${selectedDomain}`
: handle.trim()
submitting = true
try {
const response = await fetch('/oauth/sso/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token,
handle: fullHandle,
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
discord_username: discordUsername || null,
telegram_username: telegramUsername || null,
signal_username: signalUsername || null,
did_type: didType,
did: didType === 'web-external' ? externalDid.trim() : null,
}),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.message || data.error_description || data.error || $_('common.error'))
submitting = false
return
}
if (data.accessJwt && data.refreshJwt) {
localStorage.setItem('accessJwt', data.accessJwt)
localStorage.setItem('refreshJwt', data.refreshJwt)
}
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,
}))
const url = new URL(data.redirectUrl, window.location.origin)
url.searchParams.set('handle', data.handle)
url.searchParams.set('channel', verificationChannel)
window.location.href = url.pathname + url.search
return
}
window.location.href = data.redirectUrl
return
}
toast.error($_('common.error'))
submitting = false
} catch {
toast.error($_('common.error'))
submitting = false
}
}
</script>
<div class="sso-register-container">
{#if loading}
<div class="loading"></div>
{:else if error && !pending}
<div class="error-container">
<div class="error-icon">!</div>
<h2>{$_('common.error')}</h2>
<p>{error}</p>
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
</div>
{:else if pending}
<header class="page-header">
<h1>{$_('sso_register.title')}</h1>
<p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
</header>
<div class="provider-info">
<div class="provider-badge">
<SsoIcon provider={pending.provider} size={32} />
<div class="provider-details">
<span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
{#if pending.provider_username}
<span class="provider-username">@{pending.provider_username}</span>
{/if}
</div>
</div>
</div>
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleSubmit}>
<div>
<label for="handle">{$_('sso_register.handle_label')}</label>
<HandleInput
value={handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
onInput={(v) => { handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if checkingHandle}
<p class="hint">{$_('common.checking')}</p>
{:else if handleError}
<p class="hint error">{handleError}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('sso_register.handle_taken')}</p>
{:else if handleAvailable === true}
<p class="hint success">{$_('sso_register.handle_available')}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<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={verificationChannel} disabled={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 verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={email}
placeholder={$_('register.emailPlaceholder')}
disabled={submitting}
required
/>
{#if pending?.provider_email && pending?.provider_email_verified}
{#if usingVerifiedProviderEmail}
<p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
{:else}
<p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
{/if}
{/if}
</div>
{:else if verificationChannel === 'discord'}
<div class="field">
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={submitting}
required
/>
</div>
{:else if verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={submitting}
required
/>
</div>
{:else if verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</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>
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
<input
id="invite-code"
type="text"
bind:value={inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={submitting}
required
/>
</div>
{/if}
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
{submitting ? $_('common.creating') : $_('sso_register.submit')}
</button>
</form>
</div>
<aside class="info-panel">
<h3>{$_('sso_register.infoAfterTitle')}</h3>
<ul class="info-list">
<li>{$_('sso_register.infoAddPassword')}</li>
<li>{$_('sso_register.infoAddPasskey')}</li>
<li>{$_('sso_register.infoLinkProviders')}</li>
<li>{$_('sso_register.infoChangeHandle')}</li>
</ul>
</aside>
</div>
{/if}
</div>
-121
View File
@@ -1,121 +0,0 @@
<script lang="ts">
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
let code = $state('')
let trustDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = $_('common.error')
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
code: code.trim().toUpperCase(),
trust_device: trustDevice
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || $_('common.error')
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = $_('common.error')
submitting = false
} catch {
error = $_('common.error')
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
}
let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
let canSubmit = $derived(isBackupCode || isTotpCode)
</script>
<div class="oauth-totp-container">
<h1>{$_('oauth.totp.title')}</h1>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div>
<label for="code">{$_('oauth.totp.codePlaceholder')}</label>
<input
id="code"
type="text"
bind:value={code}
placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')}
disabled={submitting}
required
maxlength="8"
autocomplete="one-time-code"
autocapitalize="characters"
/>
{#if isBackupCode || isTotpCode}
<p class="hint">
{isBackupCode ? $_('oauth.totp.hintBackupCode') : $_('oauth.totp.hintTotpCode')}
</p>
{/if}
</div>
<label class="trust-device-label">
<input
type="checkbox"
bind:checked={trustDevice}
disabled={submitting}
/>
<span>{$_('oauth.totp.trustDevice')}</span>
</label>
<div class="actions">
<button type="button" class="cancel" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !canSubmit}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { getCurrentPath } from '../lib/router.svelte'
let mode = $derived(getCurrentPath().includes('totp') ? 'totp' as const : '2fa' as const)
let code = $state('')
let trustDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
function getChannel(): string {
const params = new URLSearchParams(window.location.search)
return params.get('channel') || 'email'
}
let isBackupCode = $derived(mode === 'totp' && code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
let isTotpCode = $derived(mode === 'totp' && code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
let is2faCode = $derived(mode === '2fa' && code.trim().length === 6)
let canSubmit = $derived(isBackupCode || isTotpCode || is2faCode)
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = mode === 'totp' ? $_('common.error') : $_('oauth.twoFactorCode.errors.missingRequestUri')
return
}
submitting = true
error = null
try {
const body: Record<string, unknown> = {
request_uri: requestUri,
code: mode === 'totp' ? code.trim().toUpperCase() : code.trim(),
}
if (mode === 'totp') {
body.trust_device = trustDevice
}
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(body)
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || $_('common.error')
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.unexpectedResponse') : $_('common.error')
submitting = false
} catch {
error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.connectionFailed') : $_('common.error')
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
window.history.back()
}
}
let channel = $derived(getChannel())
</script>
<div class={mode === 'totp' ? 'oauth-totp-container' : 'oauth-2fa-container'}>
<h1>{mode === 'totp' ? $_('oauth.totp.title') : $_('oauth.twoFactorCode.title')}</h1>
{#if mode === '2fa'}
<p class="subtitle">
{$_('oauth.twoFactorCode.subtitle', { values: { channel } })}
</p>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div>
<label for="code">
{mode === 'totp' ? $_('oauth.totp.codePlaceholder') : $_('oauth.twoFactorCode.codeLabel')}
</label>
{#if mode === 'totp'}
<input
id="code"
type="text"
bind:value={code}
placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')}
disabled={submitting}
required
maxlength="8"
autocomplete="one-time-code"
autocapitalize="characters"
/>
{#if isBackupCode || isTotpCode}
<p class="hint">
{isBackupCode ? $_('oauth.totp.hintBackupCode') : $_('oauth.totp.hintTotpCode')}
</p>
{/if}
{:else}
<input
id="code"
type="text"
bind:value={code}
placeholder={$_('oauth.twoFactorCode.codePlaceholder')}
disabled={submitting}
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
/>
{/if}
</div>
{#if mode === 'totp'}
<label class="trust-device-label">
<input
type="checkbox"
bind:checked={trustDevice}
disabled={submitting}
/>
<span>{$_('oauth.totp.trustDevice')}</span>
</label>
{/if}
<div class="actions">
<button type="button" class="cancel" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !canSubmit}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
+112 -200
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { navigate, routes, getFullUrl, getCurrentPath } from '../lib/router.svelte'
import { api } from '../lib/api'
import { _ } from '../lib/i18n'
import {
@@ -8,21 +8,24 @@
VerificationStep,
KeyChoiceStep,
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import type { RegistrationMode } from '../lib/registration'
import AppPasswordStep from '../components/migration/AppPasswordStep.svelte'
import PasskeySetupStep from '../components/migration/PasskeySetupStep.svelte'
import { performPasskeyRegistration, PasskeyCancelledError } from '../lib/flows/perform-passkey-registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
import IdentityTypeSection from '../components/IdentityTypeSection.svelte'
import CommsChannelPicker from '../components/CommsChannelPicker.svelte'
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
const mode: RegistrationMode = getCurrentPath().includes('register-password') ? 'password' : 'passkey'
const isPasskey = mode === 'passkey'
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
availableCommsChannels?: import('../lib/types/api').VerificationChannel[]
selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
@@ -31,6 +34,7 @@
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
let confirmPassword = $state('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
@@ -99,20 +103,24 @@
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasskeyAccount()
if (isPasskey) {
flow.createPasskeyAccount()
} else {
flow.createPasswordAccount()
}
}
})
async function loadServerInfo() {
try {
const restored = restoreRegistrationFlow()
if (restored && restored.state.mode === 'passkey') {
if (restored && restored.state.mode === mode) {
flow = restored
serverInfo = await api.describeServer()
} else {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
flow = createRegistrationFlow(mode, hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
@@ -128,6 +136,11 @@
const info = flow.info
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
if (!isPasskey) {
if (!info.password) return $_('register.validation.passwordRequired')
if (info.password.length < 8) return $_('register.validation.passwordLength')
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
}
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return $_('registerPasskey.errors.inviteRequired')
}
@@ -162,7 +175,7 @@
return
}
if (!window.PublicKeyCredential) {
if (isPasskey && !window.PublicKeyCredential) {
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
return
}
@@ -174,39 +187,25 @@
async function handlePasskeyRegistration() {
if (!flow || !flow.account) return
const { did, setupToken } = flow.account
if (!setupToken) return
flow.setSubmitting(true)
flow.clearError()
try {
const { options } = await api.startPasskeyRegistrationForSetup(
flow.account.did,
flow.account.setupToken!,
passkeyName || undefined
)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
if (!credential) {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
flow.setSubmitting(false)
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
const result = await api.completePasskeySetup(
flow.account.did,
flow.account.setupToken!,
credentialResponse,
passkeyName || undefined
)
const result = await performPasskeyRegistration({
startRegistration: () => api.startPasskeyRegistrationForSetup(
did, setupToken, passkeyName || undefined,
),
completeSetup: (credential, name) => api.completePasskeySetup(
did, setupToken, credential, name,
),
}, passkeyName || undefined)
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
} else if (err instanceof Error) {
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
@@ -221,6 +220,9 @@
async function completeOAuthRegistration() {
const requestUri = getRequestUriFromUrl()
if (!requestUri || !flow?.account) {
if (!isPasskey && flow) {
await flow.finalizeSession()
}
navigate(routes.dashboard)
return
}
@@ -235,7 +237,7 @@
body: JSON.stringify({
request_uri: requestUri,
did: flow.account.did,
app_password: flow.account.appPassword,
app_password: flow.account.appPassword || (isPasskey ? undefined : flow.info.password),
}),
})
@@ -258,26 +260,6 @@
}
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
switch (ch) {
case 'email':
return $_('register.email')
case 'discord':
return $_('register.discord')
case 'telegram':
return $_('register.telegram')
case 'signal':
return $_('register.signal')
default:
return ch
}
}
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
@@ -332,7 +314,7 @@
<div class="loading"></div>
{:else if flow}
<header class="page-header">
<h1>{$_('oauth.register.title')}</h1>
<h1>{isPasskey ? $_('oauth.register.title') : $_('register.title')}</h1>
{#if clientName}
<p class="subtitle">{$_('oauth.register.subtitle')} <strong>{clientName}</strong></p>
{/if}
@@ -354,7 +336,7 @@
</div>
</div>
<AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
<AccountTypeSwitcher active={mode} {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
<form class="register-form" onsubmit={handleInfoSubmit}>
<div>
@@ -381,124 +363,57 @@
{/if}
</div>
<div>
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{channelLabel('email')}</option>
{#if isChannelAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isChannelAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isChannelAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if !isPasskey}
<div>
<label for="password">{$_('register.password')}</label>
<input
id="password"
type="password"
bind:value={flow.info.password}
placeholder={$_('register.passwordPlaceholder')}
disabled={flow.state.submitting}
required
minlength="8"
/>
</div>
{#if flow.info.verificationChannel === 'email'}
<div>
<label for="email">{$_('register.emailAddress')}</label>
<label for="confirm-password">{$_('register.confirmPassword')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder={$_('register.confirmPasswordPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div>
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={flow.info.telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
<fieldset class="identity-section">
<legend>{$_('registerPasskey.identityType')}</legend>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
</fieldset>
<CommsChannelPicker
channel={flow.info.verificationChannel}
email={flow.info.email}
discordUsername={flow.info.discordUsername ?? ''}
telegramUsername={flow.info.telegramUsername ?? ''}
signalUsername={flow.info.signalUsername ?? ''}
availableChannels={serverInfo?.availableCommsChannels ?? ['email']}
disabled={flow.state.submitting}
onChannelChange={(ch) => { if (flow) flow.info.verificationChannel = ch }}
onEmailChange={(v) => { if (flow) flow.info.email = v }}
onDiscordChange={(v) => { if (flow) flow.info.discordUsername = v }}
onTelegramChange={(v) => { if (flow) flow.info.telegramUsername = v }}
onSignalChange={(v) => { if (flow) flow.info.signalUsername = v }}
/>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
{#if $_('registerPasskey.didWebWarning3')}
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
{/if}
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div>
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
<IdentityTypeSection
didType={flow.info.didType}
externalDid={flow.info.externalDid ?? ''}
disabled={flow.state.submitting}
selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false}
defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}
onDidTypeChange={(v) => { if (flow) flow.info.didType = v }}
onExternalDidChange={(v) => { if (flow) flow.info.externalDid = v }}
/>
{#if serverInfo?.inviteCodeRequired}
<div>
@@ -528,42 +443,34 @@
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
<DidDocStep
{flow}
type="initial"
onConfirm={() => isPasskey ? flow?.createPasskeyAccount() : flow?.createPasswordAccount()}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
<div class="loading">
<p>{$_('registerPasskey.creatingAccount')}</p>
<p>{isPasskey ? $_('registerPasskey.creatingAccount') : $_('common.creating')}</p>
</div>
{:else if flow.state.step === 'passkey'}
<div class="passkey-step">
<h2>{$_('registerPasskey.setupPasskey')}</h2>
<p>{$_('registerPasskey.passkeyDescription')}</p>
{:else if isPasskey && flow.state.step === 'passkey'}
<PasskeySetupStep
{passkeyName}
loading={flow.state.submitting}
error={flow.state.error}
onPasskeyNameChange={(n) => passkeyName = n}
onRegister={handlePasskeyRegistration}
/>
<div class="field">
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
<input
id="passkey-name"
type="text"
bind:value={passkeyName}
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
disabled={flow.state.submitting}
/>
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<button
type="button"
class="primary"
onclick={handlePasskeyRegistration}
disabled={flow.state.submitting}
>
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.createPasskey')}
</button>
</div>
{:else if flow.state.step === 'app-password'}
<AppPasswordStep {flow} />
{:else if isPasskey && flow.state.step === 'app-password'}
<AppPasswordStep
appPassword={flow.account?.appPassword ?? ''}
appPasswordName={flow.account?.appPasswordName ?? ''}
loading={flow.state.submitting}
onContinue={() => flow!.proceedFromAppPassword()}
/>
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
@@ -571,10 +478,15 @@
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
{:else if flow.state.step === 'activating'}
{:else if isPasskey && flow.state.step === 'activating'}
<div class="loading">
<p>{$_('registerPasskey.activatingAccount')}</p>
</div>
{:else if !isPasskey && flow.state.step === 'redirect-to-dashboard'}
<div class="loading">
<p>{$_('register.redirecting')}</p>
</div>
{/if}
{/if}
</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import { startOAuthRegister } from '../lib/oauth'
import { _ } from '../lib/i18n'
let error = $state<string | null>(null)
let initiated = false
$effect(() => {
if (!initiated) {
initiated = true
startOAuthRegister().catch((err) => {
error = err instanceof Error ? err.message : 'Failed to start registration'
})
}
})
</script>
<div class="register-redirect">
{#if error}
<div class="message error">{error}</div>
<a href="/app/login">{$_('register.signIn')}</a>
{:else}
<div class="loading-content">
<p>{$_('common.loading')}</p>
</div>
{/if}
</div>
-550
View File
@@ -1,550 +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,
} from '../lib/registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.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)
let selectedDomain = $state('')
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
if (!flow) return
const handle = flow.info.handle
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
if (handle.length >= 3 && !handle.includes('.')) {
checkHandleTimeout = setTimeout(() => flow?.checkHandleAvailability(handle), 400)
}
})
$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)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
} 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.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
break
case 'signal':
if (!info.signalUsername?.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 || flow.info.password,
}),
})
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()
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
})
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
async function handleCancel() {
const requestUri = getRequestUriFromUrl()
if (!requestUri) {
window.history.back()
return
}
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ request_uri: requestUri })
})
if (!response.ok) {
window.history.back()
return
}
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
} else {
window.history.back()
}
} catch {
window.history.back()
}
}
</script>
<div class="page">
<header class="page-header">
<h1>{$_('register.title')}</h1>
{#if clientName}
<p class="subtitle">{$_('oauth.register.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>
{: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()} />
<form class="register-form" onsubmit={handleInfoSubmit}>
<div>
<label for="handle">{$_('register.handle')}</label>
<HandleInput
value={flow.info.handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
{:else if flow.state.checkingHandle}
<p class="hint">{$_('common.checking')}</p>
{:else if flow.state.handleAvailable === false}
<p class="hint warning">{$_('register.handleTaken')}</p>
{:else if flow.state.handleAvailable === true && fullHandle()}
<p class="hint success">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<div>
<label for="password">{$_('register.password')}</label>
<input
id="password"
type="password"
bind:value={flow.info.password}
placeholder={$_('register.passwordPlaceholder')}
disabled={flow.state.submitting}
required
minlength="8"
/>
</div>
<div>
<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>
<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>
{#if isChannelAvailable('discord')}
<option value="discord">{$_('register.discord')}</option>
{/if}
{#if isChannelAvailable('telegram')}
<option value="telegram">{$_('register.telegram')}</option>
{/if}
{#if isChannelAvailable('signal')}
<option value="signal">{$_('register.signal')}</option>
{/if}
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div>
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={flow.info.discordUsername}
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.state.discordInUse}
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
{/if}
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div>
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={flow.info.telegramUsername}
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>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalUsername}
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
{#if flow.state.signalInUse}
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
{/if}
</div>
{/if}
<fieldset class="identity-section">
<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>
<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>
</fieldset>
{#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>
{#if $_('register.didWebWarning3')}
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
{/if}
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div>
<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}
{#if serverInfo?.inviteCodeRequired}
<div>
<label for="invite-code">{$_('register.inviteCode')}</label>
<input
id="invite-code"
type="text"
bind:value={flow.info.inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{/if}
<div class="form-actions">
<button type="button" class="secondary" onclick={handleCancel} disabled={flow.state.submitting}>
{$_('common.cancel')}
</button>
<button type="submit" class="primary" disabled={flow.state.submitting || flow.state.handleAvailable === false || flow.state.checkingHandle}>
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
</button>
</div>
</form>
{: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">
<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">
<p>{$_('register.redirecting')}</p>
</div>
{/if}
</div>
-16
View File
@@ -1,19 +1,9 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { getAuthState } from '../lib/auth.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { unsafeAsEmail } from '../lib/types/branded'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
const session = $derived(getSession())
let email = $state('')
let token = $state('')
let newPassword = $state('')
@@ -23,12 +13,6 @@
let success = $state<string | null>(null)
let tokenSent = $state(false)
$effect(() => {
if (session) {
navigate(routes.dashboard)
}
})
async function handleRequestReset(e: Event) {
e.preventDefault()
if (!email) return
+11 -52
View File
@@ -4,6 +4,7 @@
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
import IdentityTypeSection from '../components/IdentityTypeSection.svelte'
interface PendingRegistration {
request_uri: string
@@ -91,9 +92,7 @@
return commsChannels[ch as keyof CommsChannelConfig] ?? false
}
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
@@ -497,55 +496,15 @@
</div>
</fieldset>
<fieldset>
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
<IdentityTypeSection
{didType}
{externalDid}
disabled={submitting}
selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false}
defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}
onDidTypeChange={(v) => didType = v}
onExternalDidChange={(v) => externalDid = v}
/>
{#if serverInfo?.inviteCodeRequired}
<div>
-534
View File
@@ -1,534 +0,0 @@
<script lang="ts">
import { Button, Card, Input, Message, Page, Section } from '../components/ui'
import Skeleton from '../components/Skeleton.svelte'
import { toast } from '../lib/toast.svelte'
import { getServerConfigState } from '../lib/serverConfig.svelte'
import { _, locale, getSupportedLocales, localeNames, type SupportedLocale } from '../lib/i18n'
let inputValue = $state('')
let inputError = $state('')
let inputDisabled = $state('')
const serverConfig = getServerConfigState()
const LIGHT_ACCENT_DEFAULT = '#1a1d1d'
const DARK_ACCENT_DEFAULT = '#e6e8e8'
const LIGHT_SECONDARY_DEFAULT = '#1a1d1d'
const DARK_SECONDARY_DEFAULT = '#e6e8e8'
let accentLight = $state(LIGHT_ACCENT_DEFAULT)
let accentDark = $state(DARK_ACCENT_DEFAULT)
let secondaryLight = $state(LIGHT_SECONDARY_DEFAULT)
let secondaryDark = $state(DARK_SECONDARY_DEFAULT)
$effect(() => {
accentLight = serverConfig.primaryColor || LIGHT_ACCENT_DEFAULT
accentDark = serverConfig.primaryColorDark || DARK_ACCENT_DEFAULT
secondaryLight = serverConfig.secondaryColor || LIGHT_SECONDARY_DEFAULT
secondaryDark = serverConfig.secondaryColorDark || DARK_SECONDARY_DEFAULT
})
const isDark = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
)
function applyColor(prop: string, value: string): void {
document.documentElement.style.setProperty(prop, value)
}
$effect(() => {
applyColor('--accent', isDark ? accentDark : accentLight)
})
$effect(() => {
applyColor('--secondary', isDark ? secondaryDark : secondaryLight)
})
</script>
<Page title="UI Test" size="lg">
<Section title="Theme">
<div class="form-row">
<div class="field">
<label for="accent-light">Accent (light)</label>
<div class="color-pair">
<input type="color" bind:value={accentLight} />
<input id="accent-light" type="text" class="mono" bind:value={accentLight} />
</div>
</div>
<div class="field">
<label for="accent-dark">Accent (dark)</label>
<div class="color-pair">
<input type="color" bind:value={accentDark} />
<input id="accent-dark" type="text" class="mono" bind:value={accentDark} />
</div>
</div>
<div class="field">
<label for="secondary-light">Secondary (light)</label>
<div class="color-pair">
<input type="color" bind:value={secondaryLight} />
<input id="secondary-light" type="text" class="mono" bind:value={secondaryLight} />
</div>
</div>
<div class="field">
<label for="secondary-dark">Secondary (dark)</label>
<div class="color-pair">
<input type="color" bind:value={secondaryDark} />
<input id="secondary-dark" type="text" class="mono" bind:value={secondaryDark} />
</div>
</div>
<div class="field">
<label for="locale-picker">Locale</label>
<select id="locale-picker" value={$locale} onchange={(e) => locale.set(e.currentTarget.value)}>
{#each getSupportedLocales() as loc}
<option value={loc}>{localeNames[loc]} ({loc})</option>
{/each}
</select>
</div>
</div>
</Section>
<Section title="Typography">
<p style="font-size: var(--text-4xl)">4xl (2.5rem)</p>
<p style="font-size: var(--text-3xl)">3xl (2rem)</p>
<p style="font-size: var(--text-2xl)">2xl (1.5rem)</p>
<p style="font-size: var(--text-xl)">xl (1.25rem)</p>
<p style="font-size: var(--text-lg)">lg (1.125rem)</p>
<p style="font-size: var(--text-base)">base (1rem)</p>
<p style="font-size: var(--text-sm)">sm (0.875rem)</p>
<p style="font-size: var(--text-xs)">xs (0.75rem)</p>
<hr />
<p style="font-weight: var(--font-normal)">Normal (400)</p>
<p style="font-weight: var(--font-medium)">Medium (500)</p>
<p style="font-weight: var(--font-semibold)">Semibold (600)</p>
<p style="font-weight: var(--font-bold)">Bold (700)</p>
<hr />
<code>Monospace text</code>
<pre>Pre block
indented</pre>
</Section>
<Section title="Colors">
<div class="form-row">
<div>
<h4>Backgrounds</h4>
<div class="swatch" style="background: var(--bg-primary)">bg-primary</div>
<div class="swatch" style="background: var(--bg-secondary)">bg-secondary</div>
<div class="swatch" style="background: var(--bg-tertiary)">bg-tertiary</div>
<div class="swatch" style="background: var(--bg-card)">bg-card</div>
<div class="swatch" style="background: var(--bg-input)">bg-input</div>
</div>
<div>
<h4>Text</h4>
<p style="color: var(--text-primary)">text-primary</p>
<p style="color: var(--text-secondary)">text-secondary</p>
<p style="color: var(--text-muted)">text-muted</p>
<div class="swatch" style="background: var(--accent); color: var(--text-inverse)">text-inverse</div>
</div>
<div>
<h4>Accent</h4>
<div class="swatch" style="background: var(--accent); color: var(--text-inverse)">accent</div>
<div class="swatch" style="background: var(--accent-hover); color: var(--text-inverse)">accent-hover</div>
<div class="swatch" style="background: var(--accent-muted)">accent-muted</div>
</div>
<div>
<h4>Status</h4>
<div class="swatch" style="background: var(--success-bg); color: var(--success-text)">success</div>
<div class="swatch" style="background: var(--error-bg); color: var(--error-text)">error</div>
<div class="swatch" style="background: var(--warning-bg); color: var(--warning-text)">warning</div>
</div>
</div>
</Section>
<Section title="Spacing">
<div class="spacing-row">
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as i}
<div class="spacing-item">
<div class="spacing-box" style="width: var(--space-{i}); height: var(--space-{i})"></div>
<span class="text-xs text-muted">--space-{i}</span>
</div>
{/each}
</div>
</Section>
<Section title="Buttons">
<p>
<Button variant="primary">{$_('common.save')}</Button>
<Button variant="secondary">{$_('common.cancel')}</Button>
<Button variant="tertiary">{$_('common.back')}</Button>
<Button variant="danger">{$_('common.delete')}</Button>
<Button variant="ghost">{$_('common.refresh')}</Button>
</p>
<p class="mt-5">
<Button size="sm">{$_('common.verify')}</Button>
<Button size="md">{$_('common.continue')}</Button>
<Button size="lg">{$_('common.signIn')}</Button>
</p>
<p class="mt-5">
<Button disabled>{$_('common.save')}</Button>
<Button loading>{$_('common.saving')}</Button>
<Button variant="secondary" disabled>{$_('common.cancel')}</Button>
<Button variant="danger" loading>{$_('common.delete')}</Button>
</p>
<p class="mt-5">
<button class="danger-outline">{$_('common.revoke')}</button>
<button class="link">{$_('login.forgotPassword')}</button>
<button class="sm">{$_('common.done')}</button>
</p>
<div class="mt-5">
<Button fullWidth>{$_('common.signIn')}</Button>
</div>
</Section>
<Section title="Inputs">
<div class="form-row">
<div class="field">
<Input label={$_('settings.newEmail')} placeholder={$_('settings.newEmailPlaceholder')} bind:value={inputValue} />
</div>
<div class="field">
<Input label={$_('security.passkeyName')} placeholder={$_('security.passkeyNamePlaceholder')} hint={$_('appPasswords.createdMessage')} />
</div>
</div>
<div class="form-row">
<div class="field">
<Input label={$_('verification.codeLabel')} placeholder={$_('verification.codePlaceholder')} error={$_('common.error')} bind:value={inputError} />
</div>
<div class="field">
<Input label={$_('settings.yourDomain')} placeholder={$_('settings.yourDomainPlaceholder')} disabled bind:value={inputDisabled} />
</div>
</div>
<div class="form-row mt-5">
<div class="field">
<label for="demo-select">{$_('settings.language')}</label>
<select id="demo-select">
{#each getSupportedLocales() as loc}
<option>{localeNames[loc]}</option>
{/each}
</select>
</div>
<div class="field">
<label for="demo-textarea">{$_('settings.exportData')}</label>
<textarea id="demo-textarea" rows="3"></textarea>
</div>
</div>
</Section>
<Section title="Cards">
<Card>
<h4>{$_('settings.exportData')}</h4>
<p class="text-secondary text-sm">{$_('settings.downloadRepo')}</p>
</Card>
<div class="mt-4">
<Card variant="interactive">
<h4>{$_('sessions.session')}</h4>
<p class="text-secondary text-sm">{$_('sessions.current')}</p>
</Card>
</div>
<div class="mt-4">
<Card variant="danger">
<h4>{$_('security.removePassword')}</h4>
<p class="text-secondary text-sm">{$_('security.removePasswordWarning')}</p>
</Card>
</div>
</Section>
<Section title="Sections">
<Section title="Default section" description="With a description">
<p>Section content</p>
</Section>
<div class="mt-5">
<Section title="Danger section" variant="danger">
<p>Destructive operations</p>
</Section>
</div>
</Section>
<Section title="Messages">
<Message variant="success">{$_('appPasswords.deleted')}</Message>
<div class="mt-4"><Message variant="error">{$_('appPasswords.createFailed')}</Message></div>
<div class="mt-4"><Message variant="warning">{$_('security.legacyLoginWarning')}</Message></div>
<div class="mt-4"><Message variant="info">{$_('appPasswords.createdMessage')}</Message></div>
</Section>
<Section title="Badges">
<p>
<span class="badge success">{$_('inviteCodes.available')}</span>
<span class="badge warning">{$_('inviteCodes.spent')}</span>
<span class="badge error">{$_('inviteCodes.disabled')}</span>
<span class="badge accent">{$_('sessions.current')}</span>
</p>
</Section>
<Section title="Toasts">
<p>
<Button variant="secondary" onclick={() => toast.success($_('appPasswords.deleted'))}>{$_('appPasswords.deleted')}</Button>
<Button variant="secondary" onclick={() => toast.error($_('appPasswords.createFailed'))}>{$_('appPasswords.createFailed')}</Button>
<Button variant="secondary" onclick={() => toast.warning($_('security.disableTotpWarning'))}>{$_('security.disableTotpWarning')}</Button>
<Button variant="secondary" onclick={() => toast.info($_('appPasswords.createdMessage'))}>{$_('appPasswords.createdMessage')}</Button>
</p>
</Section>
<Section title="Skeleton loading">
<Skeleton variant="line" size="full" />
<Skeleton variant="line" size="medium" />
<Skeleton variant="line" size="short" />
<Skeleton variant="line" size="tiny" />
<div class="mt-5">
<Skeleton variant="line" lines={3} />
</div>
<div class="mt-5">
<Skeleton variant="card" lines={2} />
</div>
</Section>
<Section title="Fieldset">
<fieldset>
<legend>Account settings</legend>
<div class="field">
<label for="demo-display-name">Display name</label>
<input id="demo-display-name" type="text" placeholder="Name" />
</div>
</fieldset>
</Section>
<Section title="Form layouts">
<h4>Two column</h4>
<div class="form-row">
<div class="field">
<label for="demo-fname">First name</label>
<input id="demo-fname" type="text" />
</div>
<div class="field">
<label for="demo-lname">Last name</label>
<input id="demo-lname" type="text" />
</div>
</div>
<h4 class="mt-5">Three column</h4>
<div class="form-row thirds">
<div class="field">
<label for="demo-city">City</label>
<input id="demo-city" type="text" />
</div>
<div class="field">
<label for="demo-state">State</label>
<input id="demo-state" type="text" />
</div>
<div class="field">
<label for="demo-zip">ZIP</label>
<input id="demo-zip" type="text" />
</div>
</div>
<h4 class="mt-5">Full width in row</h4>
<div class="form-row">
<div class="field">
<label for="demo-handle">Handle</label>
<input id="demo-handle" type="text" />
</div>
<div class="field">
<label for="demo-domain">Domain</label>
<select id="demo-domain"><option>example.com</option></select>
</div>
<div class="field full-width">
<label for="demo-bio">Bio</label>
<textarea id="demo-bio" rows="2"></textarea>
</div>
</div>
</Section>
<Section title="Hints">
<div class="field">
<label for="demo-hint">With hints</label>
<input id="demo-hint" type="text" />
<span class="hint">Default hint</span>
</div>
<div class="field">
<input type="text" />
<span class="hint warning">Warning hint</span>
</div>
<div class="field">
<input type="text" />
<span class="hint error">Error hint</span>
</div>
<div class="field">
<input type="text" />
<span class="hint success">Success hint</span>
</div>
</Section>
<Section title="Radio group">
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="demo-radio" checked />
<div class="radio-content">
<span>Option A</span>
<span class="radio-hint">First choice</span>
</div>
</label>
<label class="radio-label">
<input type="radio" name="demo-radio" />
<div class="radio-content">
<span>Option B</span>
<span class="radio-hint">Second choice</span>
</div>
</label>
<label class="radio-label disabled">
<input type="radio" name="demo-radio" disabled />
<div class="radio-content">
<span>Option C</span>
<span class="radio-hint disabled-hint">Unavailable</span>
</div>
</label>
</div>
</Section>
<Section title="Checkbox">
<label class="checkbox-label">
<input type="checkbox" />
<span>{$_('appPasswords.acknowledgeLabel')}</span>
</label>
</Section>
<Section title="Warning box">
<div class="warning-box">
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
<p>{$_('appPasswords.saveWarningMessage')}</p>
</div>
</Section>
<Section title="Split layout">
<div class="split-layout">
<Card>
<h4>Main content</h4>
<p class="text-secondary">Primary area with a form or data</p>
<div class="field mt-5">
<label for="demo-example">Example field</label>
<input id="demo-example" type="text" placeholder="Value" />
</div>
</Card>
<div class="info-panel">
<h3>Sidebar</h3>
<p>Supplementary information placed alongside the main content area.</p>
<ul class="info-list">
<li>Supports DID methods: did:web, did:plc</li>
<li>Maximum blob size: 10MB</li>
<li>Rate limit: 100 requests per minute</li>
</ul>
</div>
</div>
</Section>
<Section title="Composite: card with form">
<Card>
<h4>{$_('appPasswords.create')}</h4>
<p class="text-secondary text-sm mb-5">{$_('appPasswords.permissions')}</p>
<div class="field">
<Input label={$_('appPasswords.name')} placeholder={$_('appPasswords.namePlaceholder')} />
</div>
<div class="mt-5" style="display: flex; gap: var(--space-3); justify-content: flex-end">
<Button variant="tertiary">{$_('common.cancel')}</Button>
<Button>{$_('appPasswords.create')}</Button>
</div>
</Card>
</Section>
<Section title="Composite: section with actions">
<Section title={$_('security.passkeys')}>
<div style="display: flex; justify-content: space-between; align-items: center">
<div>
<strong>{$_('security.totp')}</strong>
<p class="text-sm text-secondary">{$_('security.totpEnabled')}</p>
</div>
<Button variant="secondary" size="sm">{$_('security.disableTotp')}</Button>
</div>
<hr />
<div style="display: flex; justify-content: space-between; align-items: center">
<div>
<strong>{$_('security.passkeys')}</strong>
<p class="text-sm text-secondary">{$_('security.noPasskeys')}</p>
</div>
<Button variant="secondary" size="sm">{$_('security.addPasskey')}</Button>
</div>
</Section>
</Section>
<Section title="Composite: error state">
<div class="error-container">
<div class="error-icon">!</div>
<h2>Authorization failed</h2>
<p>The requested scope exceeds the granted permissions.</p>
<Button variant="secondary">Back</Button>
</div>
</Section>
<Section title="Item list">
<div class="item">
<div class="item-info">
<strong>Primary passkey</strong>
<span class="text-sm text-secondary">Created 2024-01-15</span>
</div>
<div class="item-actions">
<button class="sm">Rename</button>
<button class="sm danger-outline">Revoke</button>
</div>
</div>
<div class="item">
<div class="item-info">
<strong>Backup passkey</strong>
<span class="text-sm text-secondary">Created 2024-03-20</span>
</div>
<div class="item-actions">
<button class="sm">Rename</button>
<button class="sm danger-outline">Revoke</button>
</div>
</div>
<div class="item">
<div class="item-info">
<strong>Work laptop</strong>
<span class="text-sm text-secondary">Created 2024-06-01</span>
</div>
<div class="item-actions">
<button class="sm danger-outline">Revoke</button>
</div>
</div>
</Section>
<Section title="Definition list">
<dl class="definition-list">
<dt>Handle</dt>
<dd>@alice.example.com</dd>
<dt>DID</dt>
<dd class="mono">did:web:alice.example.com</dd>
<dt>Email</dt>
<dd>alice@example.com</dd>
<dt>Created</dt>
<dd>2024-01-15</dd>
<dt>Status</dt>
<dd><span class="badge success">Verified</span></dd>
</dl>
</Section>
<Section title="Tabs">
<div class="tabs">
<button class="tab active">PDS handle</button>
<button class="tab">Custom domain</button>
</div>
<p class="text-secondary">Tab content appears here</p>
</Section>
<Section title="Inline form">
<div class="inline-form">
<h4>Change password</h4>
<div class="field">
<label for="demo-current-pw">Current password</label>
<input id="demo-current-pw" type="password" />
</div>
<div class="field">
<label for="demo-new-pw">New password</label>
<input id="demo-new-pw" type="password" />
</div>
<div style="display: flex; gap: var(--space-3); justify-content: flex-end">
<Button variant="secondary">Cancel</Button>
<Button>Save</Button>
</div>
</div>
</Section>
</Page>
+1 -1
View File
@@ -261,7 +261,7 @@
error = null
try {
await api.resendMigrationVerification(unsafeAsEmail(identifier.trim()))
await api.resendMigrationVerification('email', identifier.trim())
resendMessage = $_('verify.codeResentDetail')
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to resend verification'
+3 -94
View File
@@ -435,6 +435,8 @@ section h3 {
.modal-backdrop {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: var(--overlay-bg);
z-index: var(--z-modal);
display: flex;
@@ -496,12 +498,6 @@ section h3 {
padding: var(--space-7);
}
.page-lg {
max-width: var(--width-xl);
margin: 0 auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
@@ -541,22 +537,6 @@ section h3 {
text-decoration: none;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-sm {
font-size: var(--text-sm);
}
.text-xs {
font-size: var(--text-xs);
}
.text-center {
text-align: center;
}
@@ -565,25 +545,6 @@ section h3 {
font-family: var(--font-mono);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-5 {
margin-top: var(--space-5);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-5 {
margin-bottom: var(--space-5);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
@@ -607,12 +568,6 @@ section h3 {
min-width: 0;
}
.form-row {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
@media (min-width: 600px) {
.form-row {
grid-template-columns: repeat(2, 1fr);
@@ -650,7 +605,6 @@ section h3 {
margin-bottom: 0;
}
.skeleton {
background: var(--bg-secondary);
}
@@ -843,10 +797,6 @@ section h3 {
min-width: 0;
}
.form-links {
margin-top: var(--space-6);
}
.form-links .link-text {
text-align: center;
color: var(--text-secondary);
@@ -989,25 +939,11 @@ a.btn:hover {
text-decoration: none;
}
.card-interactive {
cursor: pointer;
}
.card-interactive:hover {
border-color: var(--secondary);
box-shadow: 0 2px 8px var(--accent-muted);
}
.card-danger {
background: var(--error-bg);
border-color: var(--error-border);
}
.padding-none { padding: 0; }
.padding-sm { padding: var(--space-4); }
.padding-md { padding: var(--space-6); }
.padding-lg { padding: var(--space-7); }
section.danger {
background: var(--error-bg);
}
@@ -1082,26 +1018,6 @@ section .description {
pointer-events: auto;
}
.toast-success {
background: var(--success-bg);
color: var(--success-text);
}
.toast-error {
background: var(--error-bg);
color: var(--error-text);
}
.toast-warning {
background: var(--warning-bg);
color: var(--warning-text);
}
.toast-info {
background: var(--bg-secondary);
color: var(--text-primary);
}
.toast-message {
flex: 1;
font-size: var(--text-sm);
@@ -1286,7 +1202,7 @@ section .description {
margin-bottom: var(--space-4);
}
.modal-content button:not(.tab) {
.modal-content button:not(.tab):not(.secondary) {
width: 100%;
}
@@ -1338,7 +1254,6 @@ svg.sso-icon {
display: block;
}
.form-actions {
display: flex;
flex-direction: row;
@@ -1346,12 +1261,6 @@ svg.sso-icon {
margin-top: var(--space-5);
}
.cancel-row {
display: flex;
justify-content: center;
margin-top: var(--space-4);
}
.form-actions .primary {
flex: 1;
}
+17 -30
View File
@@ -279,13 +279,19 @@ button.dropdown-item.logout-item {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 0;
padding: var(--space-2) var(--space-3);
background: transparent;
border: none;
color: var(--secondary);
color: var(--text-secondary);
font-size: var(--text-base);
cursor: pointer;
margin-bottom: var(--space-2);
min-height: 44px;
}
.back-button:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
}
.back-arrow {
@@ -376,11 +382,6 @@ button.dropdown-item.logout-item {
}
}
.overview {
background: var(--bg-secondary);
padding: var(--space-6);
}
.overview dl {
display: grid;
grid-template-columns: auto 1fr;
@@ -429,7 +430,6 @@ button.dropdown-item.logout-item {
}
}
.current {
color: var(--text-secondary);
font-size: var(--text-sm);
@@ -607,7 +607,10 @@ code.record {
}
.password-actions,
.totp-actions,
.totp-actions {
display: flex;
gap: var(--space-3);
}
.remove-password-form {
background: var(--error-bg);
@@ -949,12 +952,12 @@ code.record {
font-size: var(--text-sm);
}
.sessions .detail .label {
.detail-label {
color: var(--text-secondary);
margin-right: var(--space-2);
}
.sessions .detail .value {
.detail-value {
color: var(--text-primary);
}
@@ -1688,16 +1691,7 @@ button.record-item:hover {
font-size: var(--text-sm);
}
.controllers .detail .label {
color: var(--text-secondary);
margin-right: var(--space-2);
}
.controllers .detail .value {
color: var(--text-primary);
}
.controllers .detail .value.did {
.detail-value-did {
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
@@ -2092,8 +2086,6 @@ button.typeahead-item:hover {
gap: var(--space-3);
}
.item-id {
font-weight: var(--font-medium);
font-family: var(--font-mono);
@@ -2174,12 +2166,6 @@ button.typeahead-item:hover {
font-size: var(--text-base);
}
.feature-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-4) 0;
}
.feature-list li {
padding: var(--space-2) 0;
padding-left: var(--space-4);
@@ -2263,6 +2249,7 @@ button.typeahead-item:hover {
}
button.user-item-btn:hover {
background: var(--bg-secondary);
border-color: var(--secondary);
}
@@ -2406,7 +2393,7 @@ button.remove-logo:hover {
margin-bottom: var(--space-5);
}
.admin .definition-list .mono {
.definition-mono {
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
+1 -93
View File
@@ -92,12 +92,6 @@
margin: 0 0 var(--space-5) 0;
}
.info-box {
background: var(--accent-muted);
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.info-box h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-base);
@@ -119,13 +113,6 @@
color: var(--text-secondary);
}
.warning-box {
background: var(--warning-bg);
padding: var(--space-5);
margin-bottom: var(--space-5);
font-size: var(--text-sm);
}
.warning-box strong {
color: var(--warning-text);
}
@@ -158,6 +145,7 @@
.button-row {
display: flex;
flex-direction: row;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-5);
@@ -260,28 +248,6 @@
font-size: var(--text-sm);
}
.blob-progress {
margin: var(--space-4) 0;
}
.blob-progress-bar {
height: 8px;
background: var(--bg-primary);
overflow: hidden;
margin-bottom: var(--space-2);
}
.blob-progress-fill {
height: 100%;
background: var(--accent);
}
.blob-progress-text {
text-align: center;
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0;
}
.success-content {
text-align: center;
@@ -411,43 +377,6 @@ label.auth-option {
color: var(--text-secondary);
}
.app-password-display {
background: var(--bg-primary);
padding: var(--space-5);
margin-bottom: var(--space-5);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.app-password-code {
display: block;
font-family: var(--font-mono);
font-size: var(--text-lg);
letter-spacing: 0.1em;
padding: var(--space-4);
background: var(--bg-tertiary);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
font-size: var(--text-sm);
}
.current-account {
background: var(--bg-primary);
padding: var(--space-4);
margin-bottom: var(--space-5);
display: flex;
justify-content: space-between;
align-items: center;
}
.current-account .label {
color: var(--text-secondary);
}
@@ -457,12 +386,6 @@ label.auth-option {
font-size: var(--text-lg);
}
.server-info {
background: var(--bg-primary);
padding: var(--space-4);
margin-top: var(--space-5);
}
.server-info h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-base);
@@ -488,11 +411,6 @@ label.auth-option {
font-size: var(--text-sm);
}
.final-warning {
background: var(--error-bg);
border-color: var(--error-border);
}
.final-warning strong {
color: var(--error-text);
}
@@ -596,16 +514,6 @@ label.auth-option {
margin-bottom: var(--space-4);
}
.message.success {
background: var(--success-bg);
color: var(--success-text);
}
.message.error {
background: var(--error-bg);
color: var(--error-text);
}
.handle-choice-options {
display: flex;
flex-direction: column;
+62 -145
View File
@@ -1,19 +1,3 @@
.register-redirect {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.loading-content p {
margin: 0;
color: var(--text-secondary);
@@ -85,16 +69,6 @@
margin-top: var(--space-3);
}
@media (min-width: 600px) {
.login-page .actions {
flex-direction: row;
}
.login-page .actions button {
flex: 1;
}
}
.link-text {
margin-top: var(--space-6);
font-size: var(--text-sm);
@@ -543,13 +517,6 @@ button.forget-btn:hover {
padding: 0 var(--space-2);
}
.passkey-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 500px;
}
.passkey-step h2 {
margin: 0;
}
@@ -630,13 +597,6 @@ button.forget-btn:hover {
margin-top: var(--space-4);
}
@media (min-width: 600px) {
.oauth-login .auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
}
.auth-methods {
display: grid;
grid-template-columns: 1fr;
@@ -644,25 +604,10 @@ button.forget-btn:hover {
margin-top: var(--space-4);
}
@media (min-width: 600px) {
.auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
}
.auth-methods.single-method {
grid-template-columns: 1fr;
}
@media (min-width: 600px) {
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
}
.passkey-method,
.password-method {
display: flex;
@@ -690,28 +635,6 @@ button.forget-btn:hover {
font-size: var(--text-sm);
}
@media (min-width: 600px) {
.method-divider {
flex-direction: column;
padding: 0 var(--space-3);
}
.method-divider::before,
.method-divider::after {
content: '';
width: 1px;
height: var(--space-6);
background: var(--border-color);
}
.method-divider span {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
padding: var(--space-2) 0;
}
}
@media (max-width: 599px) {
.method-divider {
gap: var(--space-4);
@@ -742,12 +665,9 @@ button.forget-btn:hover {
.oauth-login .actions {
display: flex;
gap: var(--space-4);
gap: var(--space-3);
margin-top: var(--space-2);
}
.oauth-login .actions button {
flex: 1;
justify-content: flex-end;
}
.passkey-unavailable {
@@ -854,12 +774,6 @@ button.forget-btn:hover {
background: var(--bg-secondary);
}
@media (min-width: 800px) {
.client-info {
text-align: left;
}
}
.client-logo {
width: 64px;
height: 64px;
@@ -892,21 +806,21 @@ button.forget-btn:hover {
margin-bottom: var(--space-6);
}
.consent-container .account-info .label {
.consent-account-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.consent-container .account-info .did {
.consent-account-did {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
word-break: break-all;
}
.consent-container .account-info .handle {
.consent-account-handle {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--text-primary);
@@ -1103,13 +1017,6 @@ button.forget-btn:hover {
margin-top: var(--space-6);
}
@media (min-width: 800px) {
.consent-container .actions {
max-width: 400px;
margin-left: auto;
}
}
.consent-container .actions button {
flex: 1;
padding: var(--space-3);
@@ -1308,10 +1215,6 @@ button.forget-btn:hover {
justify-content: center;
}
.oauth-register-container {
max-width: var(--width-lg);
}
.oauth-register-container .loading,
.oauth-register-container .creating {
display: flex;
@@ -1346,13 +1249,6 @@ button.forget-btn:hover {
margin-top: var(--space-2);
}
.secondary-actions {
display: flex;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
.oauth-register-container fieldset {
border: 1px solid var(--border-color);
padding: var(--space-4);
@@ -1363,10 +1259,6 @@ button.forget-btn:hover {
font-weight: var(--font-medium);
}
.sso-register-container {
max-width: var(--width-lg);
}
.sso-register-container .loading {
padding: var(--space-8);
}
@@ -1397,12 +1289,6 @@ button.forget-btn:hover {
margin-top: var(--space-3);
}
.color-pair {
display: flex;
gap: var(--space-2);
align-items: center;
}
.color-pair input[type="color"] {
width: 40px;
height: 36px;
@@ -1415,32 +1301,6 @@ button.forget-btn:hover {
flex: 1;
}
.swatch {
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-2);
font-size: var(--text-xs);
}
.spacing-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
align-items: flex-end;
}
.spacing-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.spacing-box {
background: var(--accent);
min-width: 2px;
min-height: 2px;
}
.key-choice-step {
display: flex;
flex-direction: column;
@@ -1576,3 +1436,60 @@ button.forget-btn:hover {
font-size: var(--text-sm);
margin-top: var(--space-1);
}
@media (min-width: 600px) {
.login-page .actions {
flex-direction: row;
}
.login-page .actions button {
flex: 1;
}
.oauth-login .auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
.auth-methods {
grid-template-columns: 1fr auto 1fr;
align-items: start;
}
.auth-methods.single-method {
grid-template-columns: 1fr;
max-width: 400px;
margin: var(--space-4) auto 0;
}
.method-divider {
flex-direction: column;
padding: 0 var(--space-3);
}
.method-divider::before,
.method-divider::after {
content: '';
width: 1px;
height: var(--space-6);
background: var(--border-color);
}
.method-divider span {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
padding: var(--space-2) 0;
}
}
@media (min-width: 800px) {
.client-info {
text-align: left;
}
.consent-container .actions {
max-width: 400px;
margin-left: auto;
}
}
@@ -1,17 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AtprotoClient,
base64UrlDecode,
base64UrlEncode,
buildOAuthAuthorizationUrl,
clearDPoPKey,
generateDPoPKeyPair,
generateOAuthState,
generatePKCE,
getMigrationOAuthClientId,
getMigrationOAuthRedirectUri,
loadDPoPKey,
prepareWebAuthnCreationOptions,
saveDPoPKey,
} from "../../lib/migration/atproto-client.ts";
import type { OAuthServerMetadata } from "../../lib/migration/types.ts";
@@ -23,135 +18,6 @@ describe("migration/atproto-client", () => {
localStorage.removeItem(DPOP_KEY_STORAGE);
});
describe("base64UrlEncode", () => {
it("encodes empty buffer", () => {
const result = base64UrlEncode(new Uint8Array([]));
expect(result).toBe("");
});
it("encodes simple data", () => {
const data = new TextEncoder().encode("hello");
const result = base64UrlEncode(data);
expect(result).toBe("aGVsbG8");
});
it("uses URL-safe characters (no +, /, or =)", () => {
const data = new Uint8Array([251, 255, 254]);
const result = base64UrlEncode(data);
expect(result).not.toContain("+");
expect(result).not.toContain("/");
expect(result).not.toContain("=");
});
it("replaces + with -", () => {
const data = new Uint8Array([251]);
const result = base64UrlEncode(data);
expect(result).toContain("-");
});
it("replaces / with _", () => {
const data = new Uint8Array([255]);
const result = base64UrlEncode(data);
expect(result).toContain("_");
});
it("accepts ArrayBuffer", () => {
const arrayBuffer = new ArrayBuffer(4);
const view = new Uint8Array(arrayBuffer);
view[0] = 116; // t
view[1] = 101; // e
view[2] = 115; // s
view[3] = 116; // t
const result = base64UrlEncode(arrayBuffer);
expect(result).toBe("dGVzdA");
});
});
describe("base64UrlDecode", () => {
it("decodes empty string", () => {
const result = base64UrlDecode("");
expect(result.length).toBe(0);
});
it("decodes URL-safe base64", () => {
const result = base64UrlDecode("aGVsbG8");
expect(new TextDecoder().decode(result)).toBe("hello");
});
it("handles - and _ characters", () => {
const encoded = base64UrlEncode(new Uint8Array([251, 255, 254]));
const decoded = base64UrlDecode(encoded);
expect(decoded).toEqual(new Uint8Array([251, 255, 254]));
});
it("is inverse of base64UrlEncode", () => {
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
const encoded = base64UrlEncode(original);
const decoded = base64UrlDecode(encoded);
expect(decoded).toEqual(original);
});
it("handles missing padding", () => {
const result = base64UrlDecode("YQ");
expect(new TextDecoder().decode(result)).toBe("a");
});
});
describe("generateOAuthState", () => {
it("generates a non-empty string", () => {
const state = generateOAuthState();
expect(state).toBeTruthy();
expect(typeof state).toBe("string");
});
it("generates URL-safe characters only", () => {
const state = generateOAuthState();
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("generates different values each time", () => {
const state1 = generateOAuthState();
const state2 = generateOAuthState();
expect(state1).not.toBe(state2);
});
});
describe("generatePKCE", () => {
it("generates code_verifier and code_challenge", async () => {
const pkce = await generatePKCE();
expect(pkce.codeVerifier).toBeTruthy();
expect(pkce.codeChallenge).toBeTruthy();
});
it("generates URL-safe code_verifier", async () => {
const pkce = await generatePKCE();
expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("generates URL-safe code_challenge", async () => {
const pkce = await generatePKCE();
expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("code_challenge is SHA-256 hash of code_verifier", async () => {
const pkce = await generatePKCE();
const encoder = new TextEncoder();
const data = encoder.encode(pkce.codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const expectedChallenge = base64UrlEncode(new Uint8Array(digest));
expect(pkce.codeChallenge).toBe(expectedChallenge);
});
it("generates different values each time", async () => {
const pkce1 = await generatePKCE();
const pkce2 = await generatePKCE();
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
});
});
describe("buildOAuthAuthorizationUrl", () => {
const mockMetadata: OAuthServerMetadata = {
issuer: "https://bsky.social",
@@ -398,127 +264,6 @@ describe("migration/atproto-client", () => {
});
});
describe("prepareWebAuthnCreationOptions", () => {
it("decodes challenge from base64url", () => {
const options = {
publicKey: {
challenge: "dGVzdC1jaGFsbGVuZ2U",
user: {
id: "dXNlci1pZA",
name: "test@example.com",
displayName: "Test User",
},
excludeCredentials: [],
rp: { name: "Test" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
const prepared = prepareWebAuthnCreationOptions(options);
expect(prepared.challenge).toBeInstanceOf(Uint8Array);
expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe(
"test-challenge",
);
});
it("decodes user.id from base64url", () => {
const options = {
publicKey: {
challenge: "Y2hhbGxlbmdl",
user: {
id: "dXNlci1pZA",
name: "test@example.com",
displayName: "Test User",
},
excludeCredentials: [],
rp: { name: "Test" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
const prepared = prepareWebAuthnCreationOptions(options);
expect(prepared.user?.id).toBeInstanceOf(Uint8Array);
expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe(
"user-id",
);
});
it("decodes excludeCredentials ids from base64url", () => {
const options = {
publicKey: {
challenge: "Y2hhbGxlbmdl",
user: {
id: "dXNlcg",
name: "test@example.com",
displayName: "Test User",
},
excludeCredentials: [
{ id: "Y3JlZDE", type: "public-key" },
{ id: "Y3JlZDI", type: "public-key" },
],
rp: { name: "Test" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
const prepared = prepareWebAuthnCreationOptions(options);
expect(prepared.excludeCredentials).toHaveLength(2);
expect(
new TextDecoder().decode(
prepared.excludeCredentials![0].id as Uint8Array,
),
).toBe("cred1");
expect(
new TextDecoder().decode(
prepared.excludeCredentials![1].id as Uint8Array,
),
).toBe("cred2");
});
it("handles empty excludeCredentials", () => {
const options = {
publicKey: {
challenge: "Y2hhbGxlbmdl",
user: {
id: "dXNlcg",
name: "test@example.com",
displayName: "Test User",
},
rp: { name: "Test" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
const prepared = prepareWebAuthnCreationOptions(options);
expect(prepared.excludeCredentials).toEqual([]);
});
it("preserves other user properties", () => {
const options = {
publicKey: {
challenge: "Y2hhbGxlbmdl",
user: {
id: "dXNlcg",
name: "test@example.com",
displayName: "Test User",
},
excludeCredentials: [],
rp: { name: "Test" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
const prepared = prepareWebAuthnCreationOptions(options);
expect(prepared.user?.name).toBe("test@example.com");
expect(prepared.user?.displayName).toBe("Test User");
});
});
describe("AtprotoClient.verifyHandleOwnership", () => {
function createMockJsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {

Some files were not shown because too many files have changed in this diff Show More