mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-19 08:44:13 +00:00
refactor: update routes, backend verification tweaks, and restyle
This commit is contained in:
Generated
+20
-20
@@ -6094,7 +6094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6142,7 +6142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -6165,7 +6165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6179,7 +6179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -6202,7 +6202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -6218,7 +6218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6235,7 +6235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6251,7 +6251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -6262,7 +6262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
@@ -6280,7 +6280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6303,7 +6303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -6336,7 +6336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -6424,7 +6424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -6436,7 +6436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -6461,7 +6461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -6477,7 +6477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -6514,7 +6514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,15 +10,11 @@
|
||||
signalUsername: string
|
||||
availableChannels: VerificationChannel[]
|
||||
disabled?: boolean
|
||||
discordInUse?: boolean
|
||||
telegramInUse?: boolean
|
||||
signalInUse?: boolean
|
||||
onChannelChange: (channel: VerificationChannel) => void
|
||||
onEmailChange: (value: string) => void
|
||||
onDiscordChange: (value: string) => void
|
||||
onTelegramChange: (value: string) => void
|
||||
onSignalChange: (value: string) => void
|
||||
onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -29,15 +25,11 @@
|
||||
signalUsername,
|
||||
availableChannels,
|
||||
disabled = false,
|
||||
discordInUse = false,
|
||||
telegramInUse = false,
|
||||
signalInUse = false,
|
||||
onChannelChange,
|
||||
onEmailChange,
|
||||
onDiscordChange,
|
||||
onTelegramChange,
|
||||
onSignalChange,
|
||||
onCheckInUse,
|
||||
}: Props = $props()
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
@@ -92,14 +84,10 @@
|
||||
type="text"
|
||||
value={discordUsername}
|
||||
oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('discord', discordUsername)}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
{#if discordInUse}
|
||||
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if channel === 'telegram'}
|
||||
<div>
|
||||
@@ -109,14 +97,10 @@
|
||||
type="text"
|
||||
value={telegramUsername}
|
||||
oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('telegram', telegramUsername)}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
{#if telegramInUse}
|
||||
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if channel === 'signal'}
|
||||
<div>
|
||||
@@ -126,14 +110,10 @@
|
||||
type="tel"
|
||||
value={signalUsername}
|
||||
oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('signal', signalUsername)}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
{#if signalInUse}
|
||||
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -525,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 });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,6 @@ export interface RegistrationFlowState {
|
||||
selectedDomain: string;
|
||||
handleAvailable: boolean | null;
|
||||
checkingHandle: boolean;
|
||||
discordInUse: boolean;
|
||||
telegramInUse: boolean;
|
||||
signalInUse: boolean;
|
||||
}
|
||||
|
||||
export function createRegistrationFlow(
|
||||
@@ -73,9 +70,6 @@ export function createRegistrationFlow(
|
||||
selectedDomain: "",
|
||||
handleAvailable: null,
|
||||
checkingHandle: false,
|
||||
discordInUse: false,
|
||||
telegramInUse: false,
|
||||
signalInUse: false,
|
||||
});
|
||||
|
||||
function getPdsEndpoint(): string {
|
||||
@@ -152,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") {
|
||||
@@ -498,7 +475,6 @@ export function createRegistrationFlow(
|
||||
finalizeSession,
|
||||
goBack,
|
||||
checkHandleAvailability,
|
||||
checkCommsChannelInUse,
|
||||
|
||||
setError(msg: string) {
|
||||
state.error = msg;
|
||||
|
||||
@@ -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": "验证迁移",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user