mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 02:36:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d86f026df | ||
|
|
5c8894d531 | ||
|
|
81fc03c705 | ||
|
|
f3f55e239f | ||
|
|
cf77354cde | ||
|
|
467be99da3 | ||
|
|
20212a413f | ||
|
|
6225d2bc1d | ||
|
|
b990c239e6 | ||
|
|
5b503c3f7f | ||
|
|
7f66419036 | ||
|
|
c2b8253abe |
Generated
+20
-20
@@ -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
@@ -24,7 +24,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.4"
|
||||
version = "0.4.6"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./result.ts";
|
||||
export * from "./branded.ts";
|
||||
export * from "./exhaustive.ts";
|
||||
export * from "./api.ts";
|
||||
export * from "./routes.ts";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "移行を確認",
|
||||
|
||||
@@ -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": "마이그레이션 확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "验证迁移",
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { toast } from '../lib/toast.svelte'
|
||||
import SsoIcon from '../components/SsoIcon.svelte'
|
||||
import HandleInput from '../components/HandleInput.svelte'
|
||||
import IdentityTypeSection from '../components/IdentityTypeSection.svelte'
|
||||
|
||||
interface PendingRegistration {
|
||||
request_uri: string
|
||||
@@ -91,9 +92,7 @@
|
||||
return commsChannels[ch as keyof CommsChannelConfig] ?? false
|
||||
}
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
@@ -497,55 +496,15 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{$_('registerPasskey.identityType')}</legend>
|
||||
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
|
||||
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWeb')}</strong>
|
||||
{#if serverInfo?.selfHostedDidWebEnabled === false}
|
||||
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
|
||||
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
|
||||
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<IdentityTypeSection
|
||||
{didType}
|
||||
{externalDid}
|
||||
disabled={submitting}
|
||||
selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false}
|
||||
defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}
|
||||
onDidTypeChange={(v) => didType = v}
|
||||
onExternalDidChange={(v) => externalDid = v}
|
||||
/>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div>
|
||||
|
||||
@@ -1,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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user