diff --git a/.env.example b/.env.example index fda2858b..4b2d53f4 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,12 @@ DOMAIN=keycast.example.com # PostgreSQL database password (required for docker-compose) -POSTGRES_PASSWORD=change-this-secure-password-in-production +POSTGRES_PASSWORD=change-this-secure-password-for-nonlocal # Database connection URL # Local dev: postgres://postgres:password@localhost/keycast # Docker: postgres://postgres:${POSTGRES_PASSWORD}@postgres/keycast -DATABASE_URL=postgres://postgres:password@localhost/keycast +DATABASE_URL=postgres://postgres:change-this-secure-password-for-nonlocal@localhost/keycast # Allowed public keys for admin access (comma-separated) # Leave empty to allow any authenticated user @@ -45,7 +45,7 @@ SENDGRID_API_KEY= FROM_EMAIL=noreply@keycast.example.com # Optional: From name for email notifications -FROM_NAME=diVine +FROM_NAME=Synvya # Optional: Base URL for email verification links (used in email templates) # Should match your frontend URL diff --git a/.github/workflows/build-test-push-synvya.yaml b/.github/workflows/build-test-push-synvya.yaml index 4d604401..c885b1b8 100644 --- a/.github/workflows/build-test-push-synvya.yaml +++ b/.github/workflows/build-test-push-synvya.yaml @@ -136,12 +136,21 @@ jobs: key: ${{ secrets.EC2_STAGING_SSH_KEY }} command_timeout: 30m script: | + set -euo pipefail cd /opt/synvya/keycast - git pull origin synvya-staging + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing to deploy from a dirty worktree" + git status --short + exit 1 + fi + git pull --ff-only origin synvya-staging bash scripts/load-secrets.sh staging if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env fi + docker system df || true + docker builder prune -af || true + docker image prune -af || true docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ build postgres redis migrate keycast docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ @@ -165,12 +174,21 @@ jobs: key: ${{ secrets.EC2_PRODUCTION_SSH_KEY }} command_timeout: 30m script: | + set -euo pipefail cd /opt/synvya/keycast - git pull origin synvya + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing to deploy from a dirty worktree" + git status --short + exit 1 + fi + git pull --ff-only origin synvya bash scripts/load-secrets.sh production if [ -n "${{ vars.DISABLE_EMAILS }}" ]; then echo "DISABLE_EMAILS=${{ vars.DISABLE_EMAILS }}" >> /opt/synvya/.env fi + docker system df || true + docker builder prune -af || true + docker image prune -af || true docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ build postgres redis migrate keycast docker compose -f docker-compose.synvya.yml --env-file /opt/synvya/.env \ diff --git a/.github/workflows/build-test-push.yaml b/.github/workflows/build-test-push.yaml index aa547696..1c4076a4 100644 --- a/.github/workflows/build-test-push.yaml +++ b/.github/workflows/build-test-push.yaml @@ -121,7 +121,7 @@ jobs: env: AWS_SES_TEST_RECIPIENT: ${{ vars.AWS_SES_TEST_RECIPIENT }} FROM_EMAIL: ${{ vars.AWS_SES_FROM_EMAIL || 'noreply@divine.video' }} - FROM_NAME: ${{ vars.AWS_SES_FROM_NAME || 'diVine' }} + FROM_NAME: ${{ vars.AWS_SES_FROM_NAME || 'Synvya' }} BASE_URL: https://example.com run: | cargo test -p keycast_api --features aws --test aws_ses_test -- --test-threads=1 diff --git a/.gitignore b/.gitignore index cb30aa5f..b0fbf942 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,10 @@ api/oauth-ui/ specs/ examples/auth-flows-demo/ -# Machine-specific Kamal deployment (Pi homelab only, not for production) +# Machine-specific Kamal deployment# Environment variables +.env +.env.* +!.env.example config/deploy.yml .kamal/ scripts/deploy-to-pi.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0cb76c79..9550452b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,9 @@ Optional: - `SQLX_POOL_SIZE`: Database connection pool size (should match Cloud Run concurrency, default: `50`) - `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for whitelist access (web frontend) - `ENABLE_EXAMPLES`: Enable `/examples` directory serving (default: `false`, set to `true` for development) +- `DISABLE_WEB_UI`: Disable the SvelteKit web frontend (default: `false`). When `true`, non-API requests return 404 or redirect to `WEB_UI_REDIRECT_URL`. Use for deployments (e.g. Synvya) where end users should not access a keycast personal UI. `/api/*`, `/.well-known/*`, `/health*`, `/verify-email` (plus the `/_app/*` static assets it needs), and the server-rendered `/api/oauth/authorize` approval page remain available. +- `WEB_UI_REDIRECT_URL`: When `DISABLE_WEB_UI=true`, redirect non-API requests to this URL (e.g. `https://synvya.com`). If unset, non-API requests return 404. +- `PASSWORD_RESET_BASE_URL`: Base URL used when constructing password reset links in emails (default: `BASE_URL`). Set when the reset page is hosted on a different domain than `BASE_URL` — e.g. Synvya sets this to `https://account.synvya.com` in production and `https://account.staging.synvya.com` in staging, so the link points at the Synvya-hosted reset form that POSTs to keycast's `/api/auth/reset-password`. Development (`.env` in `/web`): - `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for dev access diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4901fec8 --- /dev/null +++ b/Makefile @@ -0,0 +1,103 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Keycast Development Helper +# ───────────────────────────────────────────────────────────────────────────── + +.PHONY: check-prereq install-prereq setup migrate help dev test env-local env-staging docker-build docker-up docker-down docker-logs + +# Default target: show help +all: help + +help: ## Show this help message + @echo "🔑 \033[1;32mSynvya Keycast\033[0m" + @echo "Unified Nostr key management and event signing service." + @echo "" + @echo "\033[1;34mUsage:\033[0m" + @echo " make " + @echo "" + @echo "\033[1;34mSetup & Environment:\033[0m" + @grep -E '^[-a-zA-Z0-9_]+:.*?## (Setup|Environment).*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "\033[1;34mDevelopment & Testing:\033[0m" + @grep -E '^[-a-zA-Z0-9_]+:.*?## (Development|Quality).*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "\033[1;34mDocker & Deployment:\033[0m" + @grep -E '^[-a-zA-Z0-9_]+:.*?## Docker.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo "" + +# --- Setup & Environment --- + +check-prereq: ## Setup: Verify Rust, Bun, and SQLX are installed + @echo "==> Checking Keycast prerequisites..." + @command -v cargo >/dev/null 2>&1 || (echo " ✗ cargo (Rust) not found"; exit 1) + @command -v bun >/dev/null 2>&1 || (echo " ✗ bun not found"; exit 1) + @command -v sqlx >/dev/null 2>&1 || (echo " ✗ sqlx-cli not found. Run 'make install-prereq'"; exit 1) + @echo " ✓ All Keycast prerequisites met!" + +install-prereq: ## Setup: Install sqlx-cli tool (required for migrations) + @echo "==> Installing prerequisites..." + cargo install sqlx-cli --no-default-features --features postgres + cargo install cargo-nextest --locked + +setup: ## Setup: Initialize .env and generate master key + @echo "==> Initializing environment configuration (.env.local)..." + @if [ ! -f ".env.local" ]; then bash scripts/init.sh --domain localhost --file .env.local; fi + @if grep -q "SERVER_NSEC=$$" .env.local; then \ + echo "==> Generating SERVER_NSEC for .env.local..."; \ + RAND_SEC=$$(openssl rand -hex 32); \ + sed -i '' "s/SERVER_NSEC=.*/SERVER_NSEC=$$RAND_SEC/" .env.local || sed -i "s/SERVER_NSEC=.*/SERVER_NSEC=$$RAND_SEC/" .env.local; \ + fi + @if [ ! -f "master.key" ]; then bun run key:generate; fi + @$(MAKE) env-local + @echo " ✓ Setup complete." + +env-local: ## Environment: Set active environment to .env.local + @echo "==> Setting active environment to .env.local" + @ln -sf .env.local .env + +env-staging: ## Environment: Set active environment to .env.staging + @echo "==> Setting active environment to .env.staging" + @if [ ! -f ".env.staging" ]; then echo " ✗ .env.staging not found. Create it from .env.example"; exit 1; fi + @ln -sf .env.staging .env + +migrate: ## Environment: Run database migrations + @$(MAKE) env-check + @echo "==> Running migrations..." + bun run db:migrate + +# --- Development --- + +dev: ## Development: Start the local development stack (native) + @$(MAKE) env-check + bun run dev + +# --- Quality --- + +test: ## Quality: Run unit and integration tests + @$(MAKE) env-check + bun run test + +# --- Docker --- + +docker-build: ## Docker: Build the docker images + @$(MAKE) env-check + @echo "==> Building Docker images..." + docker compose build + +docker-up: ## Docker: Start the services via docker-compose + @$(MAKE) env-check + @echo "==> Starting Keycast stack..." + docker compose up -d + +docker-down: ## Docker: Stop the services + @$(MAKE) env-check + @echo "==> Stopping Keycast stack..." + docker compose down + +docker-logs: ## Docker: Follow docker logs + @$(MAKE) env-check + docker compose logs -f + +# --- Internal --- + +env-check: + @if [ ! -L ".env" ] && [ ! -f ".env" ]; then echo " ✗ No .env file or symlink found. Run 'make setup'"; exit 1; fi diff --git a/README.md b/README.md index de378078..0bdd649a 100644 --- a/README.md +++ b/README.md @@ -160,25 +160,26 @@ POST to `/api/nostr` with `Authorization: Bearer `: | `nip44_encrypt` / `nip44_decrypt` | NIP-44 encryption | | `nip04_encrypt` / `nip04_decrypt` | NIP-04 encryption | -## Self-Hosting +## Development & Self-Hosting + +The fastest way to get started is using the provided `Makefile`. ```bash git clone https://github.com/ArcadeLabsInc/keycast.git cd keycast -bun install -# Generate encryption key -bun run key:generate +# 1. Interactive setup (generates keys, .env.local, etc.) +make setup -# Configure environment -cp .env.example .env -# Edit DATABASE_URL, SERVER_NSEC, ALLOWED_ORIGINS +# 2. Run with Docker +make docker-build +make docker-up -# Run with Docker -docker compose up -d --build +# 3. Run tests +make test ``` -See [DEVELOPMENT.md](./docs/DEVELOPMENT.md) for local development setup. +For detailed instructions on environment management, native development, and testing architecture, see **[build.README.md](./build.README.md)**. ### Environment Variables @@ -196,7 +197,7 @@ See [DEVELOPMENT.md](./docs/DEVELOPMENT.md) for local development setup. |----------|---------|-------------| | `SENDGRID_API_KEY` | *(none)* | If set, uses SendGrid; otherwise logs emails to console | | `FROM_EMAIL` | `noreply@keycast.app` | Sender email address | -| `FROM_NAME` | `diVine` | Sender display name | +| `FROM_NAME` | `Synvya` | Sender display name | | `BASE_URL` | `https://login.divine.video` | Base URL for email verification links | | `DISABLE_EMAILS` | *(none)* | If set (any value), skips sending emails | @@ -208,6 +209,7 @@ See [DEVELOPMENT.md](./docs/DEVELOPMENT.md) for local development setup. | `APP_URL` | `https://login.divine.video` | Fallback URL for OAuth callbacks | | `ALLOWED_PUBKEYS` | *(none)* | Comma-separated admin pubkeys whitelist | | `ALLOWED_ORIGINS` | *(none)* | CORS origins (comma-separated) | +| `NODE_ENV` | `development` | Set to `production` to enable mandatory HTTPS and `Secure` cookies. | #### Multi-tenancy diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index f395e730..c988b4ca 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -729,7 +729,18 @@ pub async fn batch_create_claim_tokens( // Send email if requested if let (Some(email), Some(svc)) = (&req.delivery_email, &email_service) { - if let Err(e) = svc.send_claim_email(email, &claim_url).await { + // Best-effort kind-0 fetch for the preloaded account so the email + // can show the restaurant's display name + logo on the claim card. + // Falls back to the plain layout on any failure. + let relays = keycast_core::types::authorization::Authorization::get_bunker_relays(); + let profile = crate::nostr_profile::fetch_profile_metadata(&user_pubkey, &relays).await; + let account_display_name = profile.as_ref().and_then(|p| p.display_name.as_deref()); + let account_picture = profile.as_ref().and_then(|p| p.picture.as_deref()); + + if let Err(e) = svc + .send_claim_email(email, &claim_url, account_display_name, account_picture) + .await + { tracing::warn!( "Failed to send claim email for vine_id={} to {}: {}", vine_id, @@ -898,6 +909,262 @@ pub async fn get_user_lookup( Ok(Json(UserLookupResponse { results, total })) } +// ============================================================================ +// GET /api/admin/user-teams?pubkey= - Teams, restaurant keys, and authorizations +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct UserTeamsQuery { + pub pubkey: String, +} + +#[derive(Debug, Serialize)] +pub struct UserTeamsResponse { + pub teams: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminTeamDetails { + pub id: i32, + pub name: String, + pub role: String, + pub joined_at: String, + pub restaurant_keys: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminRestaurantKey { + pub id: i32, + pub name: String, + pub pubkey: String, + pub created_at: String, + pub authorizations: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminAuthorization { + pub id: i32, + pub label: Option, + pub bunker_public_key: String, + pub relays: Vec, + pub connected_client_pubkey: Option, + pub connected_at: Option, + pub expires_at: Option, + pub revoked_at: Option, + pub revoked_reason: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(sqlx::FromRow)] +struct AuthorizationRow { + id: i32, + label: Option, + bunker_public_key: String, + relays: String, + connected_client_pubkey: Option, + connected_at: Option>, + expires_at: Option>, + revoked_at: Option>, + revoked_reason: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +/// Return all teams the user is a member of, with the team-owned stored (restaurant) +/// keys and their authorizations. Support admin only. Tenant-scoped. +pub async fn get_user_teams( + tenant: crate::api::tenant::TenantExtractor, + State(auth_state): State, + auth: UcanAuth, + axum::extract::Query(query): axum::extract::Query, +) -> ApiResult> { + let tenant_id = tenant.0.id; + let pool = &auth_state.state.db; + + if !is_support_admin(&auth).await { + return Err(ApiError::forbidden("Admin access required")); + } + + let pubkey = query.pubkey.trim().to_lowercase(); + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ApiError::bad_request("pubkey must be a 64-char hex string")); + } + + // Teams the user belongs to (tenant-scoped) + let team_rows: Vec<(i32, String, String, chrono::DateTime)> = sqlx::query_as( + "SELECT t.id, t.name, tu.role, tu.created_at + FROM teams t + INNER JOIN team_users tu ON tu.team_id = t.id + WHERE tu.user_pubkey = $1 AND t.tenant_id = $2 + ORDER BY tu.created_at ASC", + ) + .bind(&pubkey) + .bind(tenant_id) + .fetch_all(pool) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load teams: {}", e)))?; + + let mut teams = Vec::with_capacity(team_rows.len()); + + for (team_id, team_name, role, joined_at) in team_rows { + // Stored (restaurant) keys owned by this team + let key_rows: Vec<(i32, String, String, chrono::DateTime)> = sqlx::query_as( + "SELECT id, name, pubkey, created_at + FROM stored_keys + WHERE team_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC", + ) + .bind(team_id) + .bind(tenant_id) + .fetch_all(pool) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load stored keys: {}", e)))?; + + let mut restaurant_keys = Vec::with_capacity(key_rows.len()); + + for (key_id, key_name, key_pubkey, key_created_at) in key_rows { + let auth_rows: Vec = sqlx::query_as( + "SELECT id, label, bunker_public_key, relays, connected_client_pubkey, + connected_at, expires_at, revoked_at, revoked_reason, + created_at, updated_at + FROM authorizations + WHERE stored_key_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC", + ) + .bind(key_id) + .bind(tenant_id) + .fetch_all(pool) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load authorizations: {}", e)))?; + + let authorizations = auth_rows + .into_iter() + .map(|row| { + let relays: Vec = serde_json::from_str(&row.relays).unwrap_or_default(); + AdminAuthorization { + id: row.id, + label: row.label, + bunker_public_key: row.bunker_public_key, + relays, + connected_client_pubkey: row.connected_client_pubkey, + connected_at: row.connected_at.map(|d| d.to_rfc3339()), + expires_at: row.expires_at.map(|d| d.to_rfc3339()), + revoked_at: row.revoked_at.map(|d| d.to_rfc3339()), + revoked_reason: row.revoked_reason, + created_at: row.created_at.to_rfc3339(), + updated_at: row.updated_at.to_rfc3339(), + } + }) + .collect(); + + restaurant_keys.push(AdminRestaurantKey { + id: key_id, + name: key_name, + pubkey: key_pubkey, + created_at: key_created_at.to_rfc3339(), + authorizations, + }); + } + + teams.push(AdminTeamDetails { + id: team_id, + name: team_name, + role, + joined_at: joined_at.to_rfc3339(), + restaurant_keys, + }); + } + + Ok(Json(UserTeamsResponse { teams })) +} + +// ============================================================================ +// POST /api/admin/authorizations/:id/revoke - Soft-delete a team authorization +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct RevokeAuthorizationRequest { + /// Optional free-text reason (e.g. "superseded", "admin_revoke", "client_revoke"). + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Serialize)] +pub struct RevokeAuthorizationResponse { + pub revoked: bool, + pub authorization_id: i32, +} + +/// Soft-delete a team authorization by setting `revoked_at = NOW()` and +/// notifying the signer daemon so it drops the in-memory handler. +/// Support admin only; tenant-scoped. +pub async fn revoke_authorization( + tenant: crate::api::tenant::TenantExtractor, + State(auth_state): State, + auth: UcanAuth, + Path(authorization_id): Path, + Json(req): Json, +) -> ApiResult> { + if !is_support_admin(&auth).await { + return Err(ApiError::forbidden("Admin access required")); + } + + let tenant_id = tenant.0.id; + let pool = &auth_state.state.db; + + let reason = req + .reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + + let repo = keycast_core::repositories::AuthorizationRepository::new(pool.clone()); + let bunker_pubkey = repo + .revoke(tenant_id, authorization_id, reason) + .await + .map_err(|e| ApiError::Internal(format!("Failed to revoke authorization: {}", e)))?; + + let Some(bunker_pubkey) = bunker_pubkey else { + // Either not found in this tenant, or already revoked. + return Err(ApiError::not_found( + "Authorization not found or already revoked", + )); + }; + + // Notify the signer daemon to drop the in-memory handler. If the channel + // send fails, the DB write still stands; a daemon restart will pick up the + // revoked state on next lazy load. + if let Some(tx) = &auth_state.auth_tx { + use keycast_core::authorization_channel::AuthorizationCommand; + if let Err(e) = tx + .send(AuthorizationCommand::Remove { + bunker_pubkey: bunker_pubkey.clone(), + }) + .await + { + tracing::warn!( + "Failed to notify signer of revocation for {}: {}", + bunker_pubkey, + e + ); + } + } + + tracing::info!( + "Authorization {} revoked by admin {} (tenant {}, reason: {:?})", + authorization_id, + &auth.pubkey[..8.min(auth.pubkey.len())], + tenant_id, + reason, + ); + + Ok(Json(RevokeAuthorizationResponse { + revoked: true, + authorization_id, + })) +} + // ============================================================================ // Support Admin Management (Redis-backed) // ============================================================================ diff --git a/api/src/api/http/auth.rs b/api/src/api/http/auth.rs index b560e087..beadba2d 100644 --- a/api/src/api/http/auth.rs +++ b/api/src/api/http/auth.rs @@ -41,6 +41,14 @@ pub fn token_expiry_seconds() -> i64 { .unwrap_or(DEFAULT_TOKEN_EXPIRY_HOURS * 3600) } +pub(crate) fn format_session_cookie(token: &str, max_age: i64, secure: bool) -> String { + let secure_flag = if secure { "; Secure" } else { "" }; + format!( + "keycast_session={}; HttpOnly{}; SameSite=Lax; Path=/; Max-Age={}", + token, secure_flag, max_age + ) +} + pub fn generate_secure_token() -> String { use rand::distributions::Alphanumeric; rand::thread_rng() @@ -156,6 +164,8 @@ pub struct RegisterRequest { pub nsec: Option, // Optional: user can provide their own nsec/hex secret key #[serde(skip_serializing_if = "Option::is_none")] pub relays: Option>, // Optional: user's preferred relays + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect_uri: Option, // Optional: where to redirect after email verification } #[derive(Debug, Serialize)] @@ -521,6 +531,7 @@ async fn nostr_auth_login( tenant_id: i64, headers: &HeaderMap, auth_header: &str, + secure_cookies: bool, ) -> Result { // Build expected URL for this endpoint let expected_url = build_expected_url(headers, "/api/auth/login")?; @@ -589,10 +600,7 @@ async fn nostr_auth_login( ); // Create response with UCAN session cookie - let cookie = format!( - "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400", - ucan_token - ); + let cookie = format_session_cookie(&ucan_token, 86400, secure_cookies); Ok(( axum::http::StatusCode::OK, @@ -672,6 +680,12 @@ pub async fn register( // Password hash is NULL initially - will be set by bcrypt worker // Returns Err(RepositoryError::Duplicate) if email already exists, which maps to AuthError::EmailAlreadyExists let user_repo = UserRepository::new(pool.clone()); + // Validate redirect_uri if provided (must be HTTPS or localhost HTTP) + let redirect_uri = req + .redirect_uri + .as_deref() + .filter(|uri| uri.starts_with("https://") || uri.starts_with("http://localhost")); + user_repo .register_with_personal_key( &public_key.to_hex(), @@ -681,6 +695,7 @@ pub async fn register( &verification_token, verification_expires, &encrypted_secret, + redirect_uri, ) .await?; @@ -778,12 +793,13 @@ pub async fn login( body: String, ) -> Result { let tenant_id = tenant.0.id; + let secure_cookies = auth_state.state.secure_cookies; // Check for NIP-98 Authorization header first if let Some(auth_header) = headers.get("Authorization") { if let Ok(auth_str) = auth_header.to_str() { if auth_str.starts_with("Nostr ") { - return nostr_auth_login(tenant_id, &headers, auth_str).await; + return nostr_auth_login(tenant_id, &headers, auth_str, secure_cookies).await; } } } @@ -885,10 +901,7 @@ pub async fn login( ); // Create response with UCAN session cookie - let cookie = format!( - "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400", - ucan_token - ); + let cookie = format_session_cookie(&ucan_token, 86400, secure_cookies); Ok(( axum::http::StatusCode::OK, @@ -904,11 +917,15 @@ pub async fn login( } /// Logout endpoint - clears the keycast_session cookie -pub async fn logout() -> Result { +pub async fn logout( + State(auth_state): State, +) -> Result { tracing::info!("User logging out"); + let secure_cookies = auth_state.state.secure_cookies; + // Clear the session cookie by setting Max-Age=0 - let cookie = "keycast_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0"; + let cookie = format_session_cookie("", 0, secure_cookies); let response = ( axum::http::StatusCode::OK, @@ -1540,10 +1557,17 @@ pub async fn verify_email( ); // Set UCAN session cookie - let cookie = format!( - "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400", - ucan_token - ); + let secure_cookies = auth_state.state.secure_cookies; + let cookie = format_session_cookie(&ucan_token, 86400, secure_cookies); + + // If the registration included a redirect_uri, redirect back to the client app + let redirect_to = token_data.redirect_uri.map(|uri| { + if uri.contains('?') { + format!("{}&verified=1", uri) + } else { + format!("{}?verified=1", uri) + } + }); Ok(( axum::http::StatusCode::OK, @@ -1551,7 +1575,7 @@ pub async fn verify_email( axum::Json(VerifyEmailResponse { success: true, message: "Email verified successfully! You are now logged in.".to_string(), - redirect_to: None, + redirect_to, authenticated: Some(true), status: None, retry_after: None, @@ -3093,10 +3117,8 @@ pub async fn change_key( let ucan_token = generate_ucan_token(&new_keys, tenant_id, &email, &redirect_origin, None).await?; - let cookie = format!( - "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400", - ucan_token - ); + let secure_cookies = auth_state.state.secure_cookies; + let cookie = format_session_cookie(&ucan_token, 86400, secure_cookies); let response = ChangeKeyResponse { success: true, @@ -3366,6 +3388,7 @@ mod tests { bcrypt_sender: bcrypt_queue.sender(), redis: None, secret_pool: secret_pool.receiver(), + secure_cookies: false, }), auth_tx: None, } diff --git a/api/src/api/http/claim.rs b/api/src/api/http/claim.rs index 609794af..5de9994b 100644 --- a/api/src/api/http/claim.rs +++ b/api/src/api/http/claim.rs @@ -10,6 +10,7 @@ use axum::{ use nostr_sdk::Keys; use serde::Deserialize; +use super::auth::format_session_cookie; use super::routes::AuthState; use keycast_core::repositories::{ClaimTokenRepository, UserRepository}; @@ -335,11 +336,8 @@ pub async fn claim_post( .map_err(|e| ClaimError::Internal(format!("Failed to generate session: {:?}", e)))?; // Set session cookie - let cookie_value = format!( - "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={}", - token, - 60 * 60 * 24 * 7 // 7 days - ); + let secure_cookies = auth_state.state.secure_cookies; + let cookie_value = format_session_cookie(&token, 60 * 60 * 24 * 7, secure_cookies); // Get user info for success page let user_repo = UserRepository::new(pool.clone()); @@ -502,7 +500,7 @@ pub async fn claim_post(
1
Get the App
-
Download diVine for the best experience.
+
Download Synvya for the best experience.
diff --git a/api/src/api/http/oauth.rs b/api/src/api/http/oauth.rs index ea450d38..d20bf311 100644 --- a/api/src/api/http/oauth.rs +++ b/api/src/api/http/oauth.rs @@ -18,6 +18,8 @@ use keycast_core::repositories::{ }; use keycast_core::types::refresh_token::generate_refresh_token; use nostr_sdk::{Keys, ToBech32}; + +use super::auth::format_session_cookie; use rand::Rng; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; @@ -25,6 +27,20 @@ use serde::{Deserialize, Serialize}; // Import constants and helpers from auth module use super::auth::{generate_secure_token, token_expiry_seconds, EMAIL_VERIFICATION_EXPIRY_HOURS}; +fn forgot_password_url_for_auth_host() -> &'static str { + let auth_url = std::env::var("APP_URL") + .or_else(|_| std::env::var("VITE_DOMAIN")) + .unwrap_or_default(); + + if auth_url.contains("auth.staging.synvya.com") { + "https://account.staging.synvya.com/login" + } else if auth_url.contains("auth.synvya.com") { + "https://account.synvya.com/login" + } else { + "/forgot-password" + } +} + /// Generate a 256-bit random authorization handle (64 hex characters) /// Used for silent re-authentication in OAuth flows pub fn generate_authorization_handle() -> String { @@ -1193,7 +1209,7 @@ pub async fn authorize_get(

Authorize App

@@ -1224,7 +1240,7 @@ pub async fn authorize_get(

- By authorizing, you agree to diVine's terms and privacy policy. + By authorizing, you agree to Synvya's terms and privacy policy.

@@ -1469,6 +1485,7 @@ pub async fn authorize_get( } } else { // User not authenticated - show login/register form (divine.video-inspired design) + let forgot_password_url = forgot_password_url_for_auth_host(); format!( r#" @@ -1476,7 +1493,7 @@ pub async fn authorize_get( - Sign in - diVine Login + Sign in - Synvya Login @@ -1786,7 +1803,7 @@ pub async fn authorize_get(

Sign in

@@ -1830,7 +1847,7 @@ pub async fn authorize_get( - {#if authMethod === 'cookie'} + {#if authMethod === "cookie"}
- {#if adminRole === 'full'} + {#if adminRole === "full"} Admin Dashboard {/if} - {#if adminRole === 'full' || adminRole === 'support'} + {#if adminRole === "full" || adminRole === "support"} Support Tools @@ -396,223 +464,557 @@ $effect(() => { - {#if authMethod !== 'nip07'} -
- + {#if authMethod !== "nip07"} +
+ - {#if showLearnMore} -
- + {#if showLearnMore} +
+
+

+ Your Keys Explained +

+

+ Your npub (public key) is like + a username. Share it so others can find you across + any Nostr app. +

+

+ Your nsec (private key) + proves you own this identity. Keep it safe! + Find it in + Security Settings if you need to export it. +

+
-
-

Where Is Your Key?

-

When you sign up with email and password, diVine generates a Nostr key for you and stores it on login.divine.video, encrypted using the same standards banks and password managers rely on (1,2). Your key is only decrypted in memory when an app needs to sign on your behalf, and is never stored in plain text.

-

Any Nostr app that supports diVine Login, like Priv DM , can use your identity with just your email and password. No copying keys between apps, no manual setup.

-
+
+

+ Where + Is Your Key? +

+

+ When you sign up with email and password, + Synvya generates a Nostr key for you and + stores it on login.divine.video, encrypted using the same standards banks + and password managers rely on (1,2). Your key is only decrypted in memory + when an app needs to sign on your behalf, + and is never stored in plain text. +

+

+ Any Nostr app that supports Synvya Login, + like Priv DM , can use your identity with just your + email and password. No copying keys between + apps, no manual setup. +

+
-
-

Already Have a Nostr Key?

-

You don't need a diVine account at all. Import your nsec into the diVine app and everything stays on your device.

-
+
+

+ Already Have + a Nostr Key? +

+

+ You don't need a Synvya account at all. + Import your nsec into the Synvya app and + everything stays on your device. +

+
-
-

Want Full Control of Your Key?

-

If you started with email and password but want full control, export your nsec from Security Settings and move it to:

-
    -
  • Your phone: Primal (iOS & Android), Amber (Android), or nsec.app (any browser) turn your device into a personal signing server. When a Nostr app needs your signature, it asks your device and your key never leaves it.
  • -
  • Your browser: Extensions like Alby or Soapbox Signer (Chrome, Firefox) keep your key in the browser itself. Nostash does the same for Safari on iOS.
  • -
-

With these options, each app that needs your signature must connect to your signer individually. diVine Login handles that for you automatically.

-
+
+

+ Want Full + Control of Your Key? +

+

+ If you started with email and password but + want full control, export your nsec from Security Settings and move it to: +

+
    +
  • + Your phone: + Primal + (iOS & Android), + Amber + (Android), or + nsec.app (any browser) turn your device into a personal + signing server. When a Nostr app needs your + signature, it asks your device and your key + never leaves it. +
  • +
  • + Your browser: + Extensions like + Alby + or + Soapbox Signer + (Chrome, Firefox) keep your key in the browser + itself. + Nostash does the same for Safari on iOS. +
  • +
+

+ With these options, each app that needs your + signature must connect to your signer + individually. Synvya Login handles that for + you automatically. +

+
-
-

Why This Matters

-

Unlike Twitter or Facebook, no company owns your Nostr identity. Even if diVine disappeared tomorrow, your identity and content would still exist on the network. Export your key and continue anywhere.

-

That's the power of Nostr.

-
- Explore Nostr apps at nostrapps.com +
+

+ Why This + Matters +

+

+ Unlike Twitter or Facebook, no company owns your Nostr identity. Even if Synvya disappeared tomorrow, your + identity and content would still exist on + the network. Export your key and continue + anywhere. +

+

+ That's the power of Nostr. +

+
-
- {/if} -
+ {/if} +
{/if} - {#if authMethod !== 'nip07'} -
-
-

App Connections

- -
- - {#if groupedSessions.length === 0} -
-

No app connections yet.

-

- The diVine app and any app with "Sign in with diVine" already work with your email and password. Use this to connect to other Nostr apps. Browse them at nostrapps.com. -

+ {#if authMethod !== "nip07"} +
+
+

App Connections

+
- {:else} -
- {#each groupedSessions as group} - {@const isExpanded = expandedSessions.has(group.key)} -
- - - {#if isExpanded} -
-
- {#if group.isOAuth} -
- Domain - {group.redirect_origin} -
-
- Created - {formatDate(group.earliest_created)} -
-
- Last Activity - - {group.latest_activity ? formatDate(group.latest_activity) : 'Never'} - -
-
- Total Requests - {group.total_activity} -
- {:else} - {@const session = group.sessions[0]} -
- Domain - {session.redirect_origin} -
-
- Created - {formatDate(session.created_at)} -
-
- Last Activity - - {session.last_activity ? formatDate(session.last_activity) : 'Never'} - -
-
- Total Requests - {session.activity_count} -
- {#if session.client_pubkey} -
-
- Client Pubkey -
+ + + {#if isExpanded} +
+
+ {#if group.isOAuth} +
+ Domain + {group.redirect_origin} +
+
+ Created + {formatDate( + group.earliest_created, + )} +
+
+ Last Activity + + {group.latest_activity + ? formatDate( + group.latest_activity, + ) + : "Never"} + +
+
+ Total Requests + {group.total_activity} +
+ {:else} + {@const session = + group.sessions[0]} +
+ Domain + {session.redirect_origin} +
+
+ Created + {formatDate( + session.created_at, + )} +
+
+ Last Activity + + {session.last_activity + ? formatDate( + session.last_activity, + ) + : "Never"} + +
+
+ Total Requests + {session.activity_count} +
+ {#if session.client_pubkey} +
+
- {pubkeyFormat === 'hex' ? 'npub' : 'hex'} - + Client + Pubkey + +
+
+ {formatPubkey( + session.client_pubkey, + )} + +
-
- {formatPubkey(session.client_pubkey)} + {/if} +
+
+ Bunker Pubkey + {#if !session.client_pubkey} + + {/if} +
+
+ {formatPubkey( + session.bunker_pubkey, + )}
{/if} -
-
- Bunker Pubkey - {#if !session.client_pubkey} - - {/if} -
-
- {formatPubkey(session.bunker_pubkey)} - -
-
- {/if} -
-
- +
+
+ +
-
- {/if} -
- {/each} -
- {/if} -
+ {/if} +
+ {/each} +
+ {/if} + {/if} @@ -631,16 +1033,25 @@ $effect(() => { {#if teams.length === 0}

No teams yet.

-

Teams let you manage shared Nostr keys with role-based permissions.

+

+ Teams let you manage shared Nostr keys with + role-based permissions. +

{:else}
{#each teams as team} - +
-

{team.team.name}

+

+ {team.team.name} +

- {team.team_users.length} members • {team.stored_keys.length} keys + {team.team_users.length} members • {team + .stored_keys.length} keys

@@ -665,7 +1076,13 @@ $effect(() => { {#if showRevokeModal && sessionToRevoke} -

- New to Nostr? Learn how it works + New to Nostr? Learn how it works

@@ -856,17 +1306,29 @@ $effect(() => { } .status-badge.warning { - background: color-mix(in srgb, var(--color-divine-warning) 20%, transparent); + background: color-mix( + in srgb, + var(--color-divine-warning) 20%, + transparent + ); color: var(--color-divine-warning); } .status-badge.success { - background: color-mix(in srgb, var(--color-divine-green) 20%, transparent); + background: color-mix( + in srgb, + var(--color-divine-green) 20%, + transparent + ); color: var(--color-divine-green); } .status-badge.admin { - background: color-mix(in srgb, var(--color-divine-purple, #8b5cf6) 20%, transparent); + background: color-mix( + in srgb, + var(--color-divine-purple, #8b5cf6) 20%, + transparent + ); color: var(--color-divine-purple, #8b5cf6); } @@ -920,7 +1382,11 @@ $effect(() => { .learn-link:hover { color: var(--color-divine-green); - background: color-mix(in srgb, var(--color-divine-green) 20%, transparent); + background: color-mix( + in srgb, + var(--color-divine-green) 20%, + transparent + ); } .identity-actions { @@ -1053,7 +1519,11 @@ $effect(() => { } .learn-block.highlight { - background: color-mix(in srgb, var(--color-divine-green) 8%, transparent); + background: color-mix( + in srgb, + var(--color-divine-green) 8%, + transparent + ); border-radius: 8px; padding: 1rem; border-bottom: none; @@ -1069,7 +1539,8 @@ $effect(() => { .learn-explore { margin-top: 0.75rem; padding-top: 0.75rem; - border-top: 1px solid color-mix(in srgb, var(--color-divine-green) 15%, transparent); + border-top: 1px solid + color-mix(in srgb, var(--color-divine-green) 15%, transparent); } .learn-explore a { @@ -1160,7 +1631,11 @@ $effect(() => { } .app-card:hover { - border-color: color-mix(in srgb, var(--color-divine-green) 50%, var(--color-divine-border)); + border-color: color-mix( + in srgb, + var(--color-divine-green) 50%, + var(--color-divine-border) + ); } .app-card.expanded { @@ -1202,15 +1677,29 @@ $effect(() => { } .connection-badge.oauth { - background: color-mix(in srgb, var(--color-divine-green) 15%, transparent); + background: color-mix( + in srgb, + var(--color-divine-green) 15%, + transparent + ); color: var(--color-divine-green); - border: 1px solid color-mix(in srgb, var(--color-divine-green) 30%, transparent); + border: 1px solid + color-mix(in srgb, var(--color-divine-green) 30%, transparent); } .connection-badge.manual { - background: color-mix(in srgb, var(--color-divine-text-tertiary) 10%, transparent); + background: color-mix( + in srgb, + var(--color-divine-text-tertiary) 10%, + transparent + ); color: var(--color-divine-text-secondary); - border: 1px solid color-mix(in srgb, var(--color-divine-text-tertiary) 25%, transparent); + border: 1px solid + color-mix( + in srgb, + var(--color-divine-text-tertiary) 25%, + transparent + ); } .app-domain { @@ -1438,11 +1927,11 @@ $effect(() => { } .landing-logo-img { - height: 36px; + height: 64px; } .landing-logo-sub { - font-family: 'Inter', sans-serif; + font-family: "Inter", sans-serif; font-weight: 500; font-size: 12px; letter-spacing: 3px; @@ -1527,7 +2016,11 @@ $effect(() => { .feature-icon { width: 48px; height: 48px; - background: color-mix(in srgb, var(--color-divine-green) 15%, transparent); + background: color-mix( + in srgb, + var(--color-divine-green) 15%, + transparent + ); border-radius: 12px; display: flex; align-items: center; diff --git a/web/src/routes/app/callback/+page.svelte b/web/src/routes/app/callback/+page.svelte index d3eb87df..a901f557 100644 --- a/web/src/routes/app/callback/+page.svelte +++ b/web/src/routes/app/callback/+page.svelte @@ -1,33 +1,41 @@ - Returning to App - diVine Login - + Returning to App - Synvya Login +
- - - - + + + +

Authentication Complete

Tap the button below to return to the app.

- - Open in App - + Open in App

If the app doesn't open, make sure the app is installed. @@ -89,7 +97,9 @@ const currentUrl = $derived(browser ? $page.url.href : ""); background: var(--color-divine-green); border-radius: 9999px; text-decoration: none; - transition: background 0.2s, box-shadow 0.2s; + transition: + background 0.2s, + box-shadow 0.2s; } .open-app-button:hover { diff --git a/web/src/routes/demo/+page.svelte b/web/src/routes/demo/+page.svelte index 71d83294..569da8ea 100644 --- a/web/src/routes/demo/+page.svelte +++ b/web/src/routes/demo/+page.svelte @@ -22,13 +22,8 @@ // Configuration const SERVER_URL = getViteDomain(); - const CLIENT_ID = "diVine Login Demo"; - console.log( - "SERVER_URL:", - SERVER_URL, - "VITE_DOMAIN:", - getViteDomain(), - ); + const CLIENT_ID = "Synvya Login Demo"; + console.log("SERVER_URL:", SERVER_URL, "VITE_DOMAIN:", getViteDomain()); // Create Keycast client (initialized in onMount for SSR safety) let client: ReturnType | null = null; @@ -437,7 +432,7 @@

To test the OAuth approval flow again, first revoke the "diVine Login Demo" authorizationrevoke the "Synvya Login Demo" authorization in your dashboard, then click Disconnect above.

@@ -794,7 +789,6 @@ const signed = await signer.signEvent(unsignedEvent); + import { onMount } from 'svelte'; import { toast } from 'svelte-hot-french-toast'; import { KeycastApi } from '$lib/keycast_api.svelte'; import { BRAND } from '$lib/brand'; + import { getLoginUrl } from '$lib/utils/env'; const api = new KeycastApi(); + const loginUrl = getLoginUrl(); + const isManagedExternally = loginUrl !== '/login'; let email = $state(''); let isLoading = $state(false); let emailSent = $state(false); + onMount(() => { + if (isManagedExternally) { + window.location.replace(loginUrl); + } + }); + async function handleSubmit() { if (!email) { toast.error('Please enter your email address'); @@ -38,19 +48,31 @@
- {BRAND.shortName} + {BRAND.shortName} Login

Forgot Password

-

Enter your email and we'll send you a reset link

+

+ {#if isManagedExternally} + Redirecting you to Synvya account login. + {:else} + Enter your email and we'll send you a reset link + {/if} +

- {#if emailSent} + {#if isManagedExternally} +
+

Password reset now lives in the Synvya account app.

+

If you are not redirected automatically, continue below.

+
+ Continue to Login + {:else if emailSent}

If an account exists with that email, you'll receive a password reset link shortly.

Check your inbox and spam folder.

- Back to Login + Back to Login {:else} { e.preventDefault(); handleSubmit(); }}>
@@ -72,7 +94,7 @@ {/if}
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index c0c9ff65..b104fba3 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -6,10 +6,12 @@ import { setCurrentUser } from '$lib/current_user.svelte'; import { BRAND } from '$lib/brand'; import { signin, SigninMethod, signout } from '$lib/utils/auth'; + import { getLoginUrl } from '$lib/utils/env'; import { PlugsConnected } from 'phosphor-svelte'; import { onMount } from 'svelte'; const api = new KeycastApi(); + const loginUrl = getLoginUrl(); let isNip07Loading = $state(false); let hasExtension = $state(false); let checkingSession = $state(true); @@ -157,7 +159,7 @@
- {BRAND.shortName} + {BRAND.shortName} Login @@ -239,7 +241,7 @@
-
+
+
- - {BRAND.shortName} - Login + + {#if isSynvyaManaged} + Synvya + {:else} + {BRAND.shortName} + Login + {/if} -

Create your account

-

Your Nostr identity, simplified

+ {#if !showVerificationNotice} +
+

+ {isSynvyaManaged + ? "Create your account" + : "Create your account"} +

+

+ {isSynvyaManaged + ? "Enter your details below to get started." + : "Your Nostr identity, simplified"} +

+
+ {/if} {#if showVerificationNotice}
- - + +

Check your email

-

We've sent a verification link to {registeredEmail}

-

Click the link in the email to verify your account and sign in.

- Go to Login +

+ We've sent a verification link to {registeredEmail} +

+

+ Click the link in the email to verify your account and + sign in. +

+ Go to Login
{:else} -
{ e.preventDefault(); handleRegister(); }}> -
- - -
- -
- - -
- -
- - -
- - +
+ + +
- {#if showAdvanced} -
- + +
+ +
+ + -

Import your existing key to use it with diVine Login. Leave empty to create a new one.

-
- {/if} - -
+ {#if !isSynvyaManaged} + + + {#if showAdvanced} +
+
+ + +

+ Import your existing key to use it with + Synvya Login. Leave empty to create a new + one. +

+
+
+ {/if} + {/if} + + + - {#if hasExtension} -

- Admin? Sign in with your Nostr extension -

+ {#if !isSynvyaManaged && hasExtension} +

+ Admin? Sign in with your Nostr extension +

{/if} {/if}
@@ -196,6 +257,11 @@ background: var(--color-divine-bg); } + .synvya-page { + padding: 1.5rem; + background: #ffffff; + } + .auth-container { background: var(--color-divine-surface); border: 1px solid var(--color-divine-border); @@ -206,6 +272,18 @@ box-shadow: 0 2px 8px rgba(39, 197, 139, 0.08); } + .synvya-container { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none; + max-width: 24rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + .auth-branding { display: flex; flex-direction: column; @@ -216,6 +294,10 @@ margin-bottom: 1.5rem; } + .synvya-container .auth-branding { + margin-bottom: 0; + } + .auth-branding:hover { opacity: 0.85; } @@ -224,8 +306,13 @@ height: 28px; } + .synvya-logo-img { + height: 2.75rem; + width: auto; + } + .auth-logo-sub { - font-family: 'Inter', sans-serif; + font-family: "Inter", sans-serif; font-weight: 500; font-size: 11px; letter-spacing: 3px; @@ -234,6 +321,12 @@ opacity: 0.6; } + .auth-copy { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + h1 { margin: 0 0 0.5rem 0; color: var(--color-divine-text); @@ -244,6 +337,15 @@ letter-spacing: -0.02em; } + .synvya-container h1 { + color: #0f172a; + font-family: var(--font-sans); + font-size: 1.875rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.15; + } + .subtitle { color: var(--color-divine-text-secondary); margin: 0 0 1.5rem 0; @@ -251,10 +353,25 @@ font-size: 0.95rem; } + .synvya-container .subtitle, + .synvya-container .auth-link { + color: #64748b; + } + + .synvya-container .subtitle { + margin: 0; + font-size: 0.975rem; + line-height: 1.5; + } + .form-group { margin-bottom: 1rem; } + .synvya-container .form-group { + margin-bottom: 0; + } + label { display: block; margin-bottom: 0.375rem; @@ -263,6 +380,11 @@ font-weight: 500; } + .synvya-container label { + margin-bottom: 0.5rem; + color: #0f172a; + } + input { width: 100%; padding: 0.75rem 1rem; @@ -272,7 +394,9 @@ color: var(--color-divine-text); font-size: 1rem; box-sizing: border-box; - transition: border-color 0.2s, box-shadow 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s; } input:focus { @@ -291,6 +415,30 @@ cursor: not-allowed; } + .synvya-container input { + background: #eff6ff; + border-color: #dbe4f0; + border-radius: 0.75rem; + color: #0f172a; + padding: 0.875rem 1rem; + } + + .synvya-container input::placeholder { + color: #9aa7b8; + opacity: 1; + } + + .synvya-container input:focus { + border-color: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); + } + + .synvya-container form { + display: flex; + flex-direction: column; + gap: 1rem; + } + .advanced-toggle { display: flex; align-items: center; @@ -346,11 +494,22 @@ margin-top: 0.5rem; } + .synvya-container .btn-primary { + margin-top: 0.25rem; + border-radius: 0.75rem; + background: #22c55e; + box-shadow: none; + } + .btn-primary:hover:not(:disabled) { background: var(--color-divine-green-dark); box-shadow: 0 2px 8px rgba(39, 197, 139, 0.16); } + .synvya-container .btn-primary:hover:not(:disabled) { + background: #16a34a; + } + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; @@ -373,6 +532,13 @@ text-decoration: underline; } + .synvya-container .auth-link a { + color: #334155; + text-decoration: underline; + text-underline-offset: 2px; + font-weight: 400; + } + .auth-note { text-align: center; margin-top: 1.5rem; @@ -406,6 +572,10 @@ color: var(--color-divine-green); } + .synvya-container .verification-notice .notice-icon.success { + color: #22c55e; + } + .verification-notice h2 { font-size: 1.25rem; font-weight: 600; @@ -413,6 +583,11 @@ margin-bottom: 0.5rem; } + .synvya-container .verification-notice h2 { + color: #0f172a; + font-size: 1.5rem; + } + .verification-notice p { color: var(--color-divine-text-secondary); font-size: 0.9rem; @@ -420,10 +595,18 @@ margin-bottom: 0.5rem; } + .synvya-container .verification-notice p { + color: #64748b; + } + .verification-notice strong { color: var(--color-divine-text); } + .synvya-container .verification-notice strong { + color: #0f172a; + } + .verification-notice .subtext { font-size: 0.8rem; margin-bottom: 1.5rem; @@ -443,8 +626,29 @@ transition: all 0.2s; } + .synvya-container .btn-secondary { + border-radius: 0.75rem; + color: #334155; + border-color: #dbe4f0; + } + .btn-secondary:hover { background: var(--color-divine-muted); color: var(--color-divine-text); } + + .synvya-container .btn-secondary:hover { + background: #f8fafc; + border-color: #94a3b8; + } + + @media (max-width: 640px) { + .synvya-page { + padding: 1.25rem; + } + + .synvya-container h1 { + font-size: 1.625rem; + } + } diff --git a/web/src/routes/reset-password/+page.svelte b/web/src/routes/reset-password/+page.svelte index 411596dc..50646c1b 100644 --- a/web/src/routes/reset-password/+page.svelte +++ b/web/src/routes/reset-password/+page.svelte @@ -4,15 +4,29 @@ import { toast } from 'svelte-hot-french-toast'; import { KeycastApi } from '$lib/keycast_api.svelte'; import { BRAND } from '$lib/brand'; + import { getLoginUrl } from '$lib/utils/env'; const api = new KeycastApi(); + const loginUrl = getLoginUrl(); + const isSynvyaManaged = loginUrl !== '/login'; + const pageTitle = isSynvyaManaged ? 'Reset Password - Synvya' : `Reset Password - ${BRAND.name}`; let password = $state(''); let confirmPassword = $state(''); let isLoading = $state(false); + let showSuccess = $state(false); const token = $derived($page.url.searchParams.get('token')); + function redirectToLogin() { + if (loginUrl.startsWith('http://') || loginUrl.startsWith('https://')) { + window.location.assign(loginUrl); + return; + } + + goto(loginUrl); + } + async function handleSubmit() { if (!token) { toast.error('Invalid or missing reset token'); @@ -38,7 +52,7 @@ }); toast.success('Password reset successfully!'); - goto('/login'); + showSuccess = true; } catch (err: any) { console.error('Reset password error:', err); toast.error(err.message || 'Failed to reset password. The link may have expired.'); @@ -49,62 +63,85 @@ - Reset Password - {BRAND.name} + {pageTitle} -
-
- - {BRAND.shortName} - Login +
+
+ + {#if isSynvyaManaged} + Synvya + {:else} + {BRAND.shortName} + Login + {/if} -

Reset Password

-

Enter your new password

- - {#if !token} -
-

Invalid or missing reset token.

-

Please request a new password reset link.

+ {#if showSuccess} +
+
+ + + +
+

Password updated

+

Your password has been reset successfully. You can now sign in with your new password.

+ {#if isSynvyaManaged && loginUrl.startsWith('http')} + Sign in + {:else} + Sign in + {/if}
- Request New Link {:else} -
{ e.preventDefault(); handleSubmit(); }}> -
- - -
+
+

{isSynvyaManaged ? 'Set new password' : 'Reset Password'}

+

{isSynvyaManaged ? 'Enter your new password below.' : 'Enter your new password'}

+
-
- - + {#if !token} +
+

Invalid or missing reset token.

+

Please request a new password reset link.

- - - + Request New Link + {:else} +
{ e.preventDefault(); handleSubmit(); }}> +
+ + +
+ +
+ + +
+ + +
+ {/if} + + {/if} - -
@@ -118,6 +155,11 @@ background: var(--color-divine-bg); } + .synvya-page { + padding: 1.5rem; + background: #ffffff; + } + .auth-container { background: var(--color-divine-surface); border: 1px solid var(--color-divine-border); @@ -128,6 +170,18 @@ box-shadow: 0 2px 8px rgba(39, 197, 139, 0.08); } + .synvya-container { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none; + max-width: 24rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + .auth-branding { display: flex; flex-direction: column; @@ -146,6 +200,17 @@ height: 28px; } + .synvya-logo-img { + height: 2.75rem; + width: auto; + } + + .auth-copy { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .auth-logo-sub { font-family: 'Inter', sans-serif; font-weight: 500; @@ -166,6 +231,15 @@ letter-spacing: -0.02em; } + .synvya-container h1 { + color: #0f172a; + font-family: var(--font-sans); + font-size: 1.875rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.15; + } + .subtitle { color: var(--color-divine-text-secondary); margin: 0 0 1.5rem 0; @@ -173,10 +247,25 @@ font-size: 0.95rem; } + .synvya-container .subtitle, + .synvya-container .auth-link { + color: #64748b; + } + + .synvya-container .subtitle { + margin: 0; + font-size: 0.975rem; + line-height: 1.5; + } + .form-group { margin-bottom: 1rem; } + .synvya-container .form-group { + margin-bottom: 0; + } + label { display: block; margin-bottom: 0.375rem; @@ -185,6 +274,11 @@ font-weight: 500; } + .synvya-container label { + margin-bottom: 0.5rem; + color: #0f172a; + } + input { width: 100%; padding: 0.75rem 1rem; @@ -213,6 +307,30 @@ cursor: not-allowed; } + .synvya-container input { + background: #eff6ff; + border-color: #dbe4f0; + border-radius: 0.75rem; + color: #0f172a; + padding: 0.875rem 1rem; + } + + .synvya-container input::placeholder { + color: #9aa7b8; + opacity: 1; + } + + .synvya-container input:focus { + border-color: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); + } + + .synvya-container form { + display: flex; + flex-direction: column; + gap: 1rem; + } + .btn-primary { display: block; width: 100%; @@ -230,11 +348,22 @@ margin-top: 0.5rem; } + .synvya-container .btn-primary { + margin-top: 0.25rem; + border-radius: 0.75rem; + background: #22c55e; + box-shadow: none; + } + .btn-primary:hover:not(:disabled) { background: var(--color-divine-green-dark); box-shadow: 0 2px 8px rgba(39, 197, 139, 0.16); } + .synvya-container .btn-primary:hover:not(:disabled) { + background: #16a34a; + } + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; @@ -257,6 +386,13 @@ text-decoration: underline; } + .synvya-container .auth-link a { + color: #334155; + text-decoration: underline; + text-underline-offset: 2px; + font-weight: 400; + } + .error-message { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--color-divine-error); @@ -273,4 +409,53 @@ .error-message p:last-child { margin-bottom: 0; } + + .synvya-container .error-message { + margin-bottom: 0; + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; + } + + .success-notice { + text-align: center; + padding: 1rem 0; + } + + .success-notice .notice-icon { + display: flex; + justify-content: center; + margin-bottom: 1rem; + } + + .success-notice .notice-icon.success { + color: var(--color-divine-green); + } + + .synvya-container .success-notice .notice-icon.success { + color: #22c55e; + } + + .success-notice h1 { + margin-bottom: 0.5rem; + } + + .success-notice .subtitle { + margin-bottom: 1.5rem; + } + + .synvya-container .success-notice .subtitle { + color: #64748b; + margin-bottom: 1.5rem; + } + + @media (max-width: 640px) { + .synvya-page { + padding: 1.25rem; + } + + .synvya-container h1 { + font-size: 1.625rem; + } + } diff --git a/web/src/routes/support-admin/+page.svelte b/web/src/routes/support-admin/+page.svelte index 19952e40..6f930476 100644 --- a/web/src/routes/support-admin/+page.svelte +++ b/web/src/routes/support-admin/+page.svelte @@ -3,12 +3,15 @@ import { BRAND } from '$lib/brand'; import { KeycastApi } from '$lib/keycast_api.svelte'; import { goto } from '$app/navigation'; + import { getLoginUrl } from '$lib/utils/env'; import Loader from '$lib/components/Loader.svelte'; - import { ShieldCheck, Warning, MagnifyingGlass, User, Key, Calendar, Globe, Copy, Check, CheckCircle, XCircle, Link, CaretDown, CaretRight } from 'phosphor-svelte'; + import { ShieldCheck, Warning, MagnifyingGlass, User, Key, Calendar, Globe, Copy, Check, CheckCircle, XCircle, Link, CaretDown, CaretRight, Storefront, UsersThree, Plug } from 'phosphor-svelte'; import { nip19 } from 'nostr-tools'; import { toast } from 'svelte-hot-french-toast'; const api = new KeycastApi(); + const isSynvyaManaged = getLoginUrl() !== '/login'; + const brandName = isSynvyaManaged ? 'Synvya' : BRAND.name; let status = $state<'loading' | 'not-admin' | 'ready'>('loading'); let adminRole = $state(null); @@ -32,6 +35,41 @@ let isGeneratingClaimToken = $state(false); let copiedClaimUrl = $state(false); + // Teams / restaurants / authorizations state (lazy-loaded per expand) + let userTeams = $state(null); + let isLoadingTeams = $state(false); + let teamsError = $state(''); + + interface AdminAuthorization { + id: number; + label: string | null; + bunker_public_key: string; + relays: string[]; + connected_client_pubkey: string | null; + connected_at: string | null; + expires_at: string | null; + revoked_at: string | null; + revoked_reason: string | null; + created_at: string; + updated_at: string; + } + + interface RestaurantKey { + id: number; + name: string; + pubkey: string; + created_at: string; + authorizations: AdminAuthorization[]; + } + + interface TeamDetails { + id: number; + name: string; + role: string; + joined_at: string; + restaurant_keys: RestaurantKey[]; + } + interface UserDetails { pubkey: string; email: string | null; @@ -173,6 +211,148 @@ expandedPubkey = expandedPubkey === pubkey ? null : pubkey; } + async function loadUserTeams(pubkey: string) { + isLoadingTeams = true; + teamsError = ''; + userTeams = null; + try { + const result = await api.get<{ teams: TeamDetails[] }>( + `/admin/user-teams?pubkey=${encodeURIComponent(pubkey)}` + ); + userTeams = result.teams; + } catch (err: any) { + teamsError = err.message || 'Failed to load teams'; + } finally { + isLoadingTeams = false; + } + } + + function formatAuthLabel(a: AdminAuthorization): string { + return a.label && a.label.trim() ? a.label : `Authorization #${a.id}`; + } + + function isSynvyaServerAuth(label: string | null): boolean { + return !!label && /synvya\s+server/i.test(label); + } + + function isSynvyaClientAuth(label: string | null): boolean { + return !!label && /synvya\s+client/i.test(label); + } + + interface AuthGroup { + key: string; // group key for UI (`(no label)` for empty labels) + label: string | null; + displayLabel: string; + serverAuth: boolean; + clientAuth: boolean; + authorizations: AdminAuthorization[]; // newest-first + } + + /** Group authorizations by label, newest-first within each group. */ + function groupAuthorizations(auths: AdminAuthorization[]): AuthGroup[] { + const groups = new Map(); + for (const a of auths) { + const labelNorm = a.label && a.label.trim() ? a.label.trim() : ''; + const key = labelNorm || '(no label)'; + let group = groups.get(key); + if (!group) { + group = { + key, + label: labelNorm || null, + displayLabel: labelNorm || '(no label)', + serverAuth: isSynvyaServerAuth(labelNorm || null), + clientAuth: isSynvyaClientAuth(labelNorm || null), + authorizations: [], + }; + groups.set(key, group); + } + group.authorizations.push(a); + } + for (const group of groups.values()) { + group.authorizations.sort( + (x, y) => new Date(y.created_at).getTime() - new Date(x.created_at).getTime(), + ); + } + // Order groups by the most recent authorization within each group (newest first). + return Array.from(groups.values()).sort( + (a, b) => + new Date(b.authorizations[0].created_at).getTime() - + new Date(a.authorizations[0].created_at).getTime(), + ); + } + + // Per-group expand/collapse for older authorizations in the Synvya admin view. + // Keyed by `${restaurantKeyId}|${groupKey}`; presence = expanded. + let expandedAuthGroups = $state>(new Set()); + + function authGroupExpandKey(keyId: number, groupKey: string): string { + return `${keyId}|${groupKey}`; + } + + function toggleAuthGroup(keyId: number, groupKey: string) { + const k = authGroupExpandKey(keyId, groupKey); + const next = new Set(expandedAuthGroups); + if (next.has(k)) { + next.delete(k); + } else { + next.add(k); + } + expandedAuthGroups = next; + } + + // Show revoked authorizations per restaurant key (keyed by RestaurantKey.id). + let showRevokedKeys = $state>(new Set()); + function toggleShowRevoked(keyId: number) { + const next = new Set(showRevokedKeys); + if (next.has(keyId)) next.delete(keyId); else next.add(keyId); + showRevokedKeys = next; + } + + function isRevoked(a: AdminAuthorization): boolean { + return a.revoked_at !== null && a.revoked_at !== undefined; + } + + function countRevoked(auths: AdminAuthorization[]): number { + return auths.filter(isRevoked).length; + } + + function visibleAuthorizations(key: RestaurantKey): AdminAuthorization[] { + if (showRevokedKeys.has(key.id)) return key.authorizations; + return key.authorizations.filter(a => !isRevoked(a)); + } + + let revokingAuthIds = $state>(new Set()); + + async function revokeAuthorization(authId: number) { + const reason = window.prompt( + `Revoke authorization #${authId}?\n\nOptional: enter a reason (visible in admin audit).`, + '', + ); + // prompt returns null if user cancels; empty string is OK (reason optional). + if (reason === null) return; + const trimmed = reason.trim(); + + const next = new Set(revokingAuthIds); + next.add(authId); + revokingAuthIds = next; + try { + await api.post(`/admin/authorizations/${authId}/revoke`, { + reason: trimmed.length > 0 ? trimmed : null, + }); + toast.success(`Authorization #${authId} revoked`); + if (expandedPubkey) { + await loadUserTeams(expandedPubkey); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast.error(`Failed to revoke: ${msg}`); + } finally { + const after = new Set(revokingAuthIds); + after.delete(authId); + revokingAuthIds = after; + } + } + $effect(() => { if (expandedPubkey && searchResult) { const user = searchResult.results.find(u => u.pubkey === expandedPubkey); @@ -181,17 +361,26 @@ } else { claimToken = null; } + loadUserTeams(expandedPubkey); } else { claimToken = null; + userTeams = null; + teamsError = ''; } }); - Support Admin - {BRAND.name} + Support Admin - {brandName} -
+
+ {#if isSynvyaManaged} +
+ + Support Admin +
+ {/if} {#if status === 'loading'}
@@ -388,6 +577,242 @@ {/if}
{/if} + + {#if isSynvyaManaged} +
+
+ + Teams & Restaurants +
+ + {#if isLoadingTeams} +

Loading teams…

+ {:else if teamsError} +
{teamsError}
+ {:else if userTeams && userTeams.length === 0} +

This user is not a member of any team.

+ {:else if userTeams} +
+ {#each userTeams as team (team.id)} +
+
+
+ + {team.name} +
+
+ {team.role} + joined {formatDate(team.joined_at)} +
+
+ + {#if team.restaurant_keys.length === 0} +

No restaurant keys in this team.

+ {:else} + {#each team.restaurant_keys as key (key.id)} +
+
+ Restaurant + {key.name} +
+
+ Pubkey + + {truncateFormatted(key.pubkey)} + + +
+ +
+ + Authorizations + shared across the team + {#if isSynvyaManaged && countRevoked(key.authorizations) > 0} + + {/if} +
+ + {#if key.authorizations.length === 0} +

No authorizations on this key.

+ {:else if isSynvyaManaged} + {@const visibleAuths = visibleAuthorizations(key)} +
+ {#if visibleAuths.length === 0} +

All authorizations on this key are revoked. Click "Show revoked" above to view them.

+ {/if} + {#each groupAuthorizations(visibleAuths) as group (group.key)} + {@const expanded = expandedAuthGroups.has(authGroupExpandKey(key.id, group.key))} + {@const newest = group.authorizations[0]} + {@const olderCount = group.authorizations.length - 1} +
+
+ {group.displayLabel} + · {group.authorizations.length} + {#if group.serverAuth} + 24/7 + {:else if group.clientAuth} + interactive + {/if} +
+
+
+ Authorization #{newest.id} · newest + {#if isRevoked(newest)} + revoked + {/if} +
+
+
+ Bunker + {truncateFormatted(newest.bunker_public_key)} +
+
+ Created + {formatDate(newest.created_at)} +
+
+ Connected + {newest.connected_at ? formatDate(newest.connected_at) : '—'} +
+
+ Expires + {newest.expires_at ? formatDate(newest.expires_at) : 'Never'} +
+
+ Relays + {newest.relays.length === 0 ? '—' : newest.relays.join(', ')} +
+
+ {#if isRevoked(newest)} +
+ Revoked {formatDate(newest.revoked_at!)} + {#if newest.revoked_reason} + "{newest.revoked_reason}" + {/if} +
+ {:else} +
+ +
+ {/if} +
+ {#if olderCount > 0} + + {#if expanded} +
+ {#each group.authorizations.slice(1) as a (a.id)} +
+
+ Authorization #{a.id} + {#if isRevoked(a)} + revoked + {/if} +
+
+
+ Bunker + {truncateFormatted(a.bunker_public_key)} +
+
+ Created + {formatDate(a.created_at)} +
+
+ Connected + {a.connected_at ? formatDate(a.connected_at) : '—'} +
+
+ Expires + {a.expires_at ? formatDate(a.expires_at) : 'Never'} +
+
+ Relays + {a.relays.length === 0 ? '—' : a.relays.join(', ')} +
+
+ {#if isRevoked(a)} +
+ Revoked {formatDate(a.revoked_at!)} + {#if a.revoked_reason} + "{a.revoked_reason}" + {/if} +
+ {:else} +
+ +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
+ {/each} +
+ {:else} +
+ {#each key.authorizations as a (a.id)} + {@const serverAuth = isSynvyaServerAuth(a.label)} + {@const clientAuth = isSynvyaClientAuth(a.label)} +
+
+ {formatAuthLabel(a)} + {#if serverAuth} + 24/7 + {:else if clientAuth} + interactive + {/if} +
+
+
+ Bunker + {truncateFormatted(a.bunker_public_key)} +
+
+ Created + {formatDate(a.created_at)} +
+
+ Connected + {a.connected_at ? formatDate(a.connected_at) : '—'} +
+
+ Expires + {a.expires_at ? formatDate(a.expires_at) : 'Never'} +
+
+ Relays + {a.relays.length === 0 ? '—' : a.relays.join(', ')} +
+
+
+ {/each} +
+ {/if} +
+ {/each} + {/if} +
+ {/each} +
+ {/if} +
+ {/if}
{/if}
@@ -909,4 +1334,533 @@ color: var(--color-divine-text-tertiary); font-size: 0.8rem; } + + /* ========================================================= + Teams & Authorizations (shared markup, inherits dark theme) + ========================================================= */ + .teams-section { + padding: 0.875rem 1.25rem 1rem; + border-top: 1px solid var(--color-divine-border); + } + + .section-header { + display: flex; + align-items: center; + gap: 0.375rem; + color: var(--color-divine-text); + margin-bottom: 0.625rem; + } + + .section-title { + font-size: 0.825rem; + font-weight: 600; + } + + .muted-text { + font-size: 0.8rem; + color: var(--color-divine-text-tertiary); + margin: 0.25rem 0; + } + + .muted-text.indent { + padding-left: 0.5rem; + } + + .inline-error { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8rem; + color: var(--color-divine-error); + } + + .teams-list { + display: flex; + flex-direction: column; + gap: 0.625rem; + } + + .team-card { + background: var(--color-divine-bg); + border: 1px solid var(--color-divine-border); + border-radius: 10px; + padding: 0.75rem 0.875rem; + } + + .team-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + } + + .team-header-main { + display: flex; + align-items: center; + gap: 0.375rem; + color: var(--color-divine-green); + } + + .team-name { + color: var(--color-divine-text); + font-weight: 600; + font-size: 0.9rem; + } + + .team-header-meta { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .team-joined { + font-size: 0.7rem; + color: var(--color-divine-text-tertiary); + } + + .pill { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + font-size: 0.65rem; + font-weight: 600; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .pill-role { + background: color-mix(in srgb, var(--color-divine-purple, #8b5cf6) 18%, transparent); + color: var(--color-divine-purple, #8b5cf6); + } + + .pill-server { + background: color-mix(in srgb, var(--color-divine-green) 20%, transparent); + color: var(--color-divine-green); + } + + .pill-client { + background: color-mix(in srgb, #3b82f6 20%, transparent); + color: #3b82f6; + } + + .pill-revoked { + background: color-mix(in srgb, #dc2626 18%, transparent); + color: #dc2626; + } + + .auth-card-revoked { + opacity: 0.65; + border-color: color-mix(in srgb, #dc2626 40%, var(--color-divine-border)); + background: color-mix(in srgb, #dc2626 4%, transparent); + } + + .auth-card-revoked .auth-sub-label { + text-decoration: line-through; + } + + .auth-revoked-info { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.4rem 0.6rem; + margin-top: 0.35rem; + border-top: 1px dashed color-mix(in srgb, #dc2626 40%, var(--color-divine-border)); + font-size: 0.78rem; + } + + .auth-revoked-label { + color: #dc2626; + font-weight: 600; + } + + .auth-revoked-reason { + color: var(--color-divine-text-muted, #64748b); + font-style: italic; + } + + .auth-card-actions { + display: flex; + justify-content: flex-end; + padding: 0.35rem 0.5rem 0.5rem; + } + + .btn-revoke { + background: transparent; + border: 1px solid color-mix(in srgb, #dc2626 60%, transparent); + color: #dc2626; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.55rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + + .btn-revoke:hover:not(:disabled) { + background: #dc2626; + color: white; + } + + .btn-revoke:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .show-revoked-toggle { + margin-left: auto; + background: transparent; + border: 1px solid var(--color-divine-border); + color: var(--color-divine-text-muted, #64748b); + font-size: 0.72rem; + padding: 0.12rem 0.45rem; + border-radius: 5px; + cursor: pointer; + } + + .show-revoked-toggle:hover { + background: var(--color-divine-surface); + color: var(--color-divine-text, inherit); + } + + .restaurant-block { + margin-top: 0.5rem; + padding: 0.625rem 0.75rem; + background: var(--color-divine-surface); + border: 1px solid var(--color-divine-border); + border-radius: 8px; + } + + .restaurant-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.2rem 0; + font-size: 0.8rem; + } + + .restaurant-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--color-divine-text-secondary); + } + + .restaurant-name { + color: var(--color-divine-text); + font-weight: 500; + } + + .auth-header { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.625rem; + margin-bottom: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-divine-text-secondary); + } + + .auth-scope-hint { + margin-left: auto; + font-weight: 400; + font-size: 0.7rem; + color: var(--color-divine-text-tertiary); + font-style: italic; + } + + .auth-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .auth-card { + border: 1px solid var(--color-divine-border); + border-radius: 8px; + padding: 0.5rem 0.625rem; + background: var(--color-divine-bg); + } + + .auth-card.auth-server { + border-left: 3px solid var(--color-divine-green); + } + + .auth-card.auth-client { + border-left: 3px solid #3b82f6; + } + + /* Grouped authorizations (Synvya admin view) */ + .auth-group { + border: 1px solid var(--color-divine-border); + border-radius: 8px; + background: var(--color-divine-bg); + padding: 0.5rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .auth-group.auth-server { + border-left: 3px solid var(--color-divine-green); + } + + .auth-group.auth-client { + border-left: 3px solid #3b82f6; + } + + .auth-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .auth-count-badge { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-divine-text-secondary); + } + + .auth-card-plain { + border: none; + border-left: none; + padding: 0.375rem 0; + background: transparent; + border-radius: 0; + } + + .auth-sub-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-divine-text-secondary); + } + + .auth-older-toggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: 1px dashed var(--color-divine-border); + border-radius: 6px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + color: var(--color-divine-text-secondary); + cursor: pointer; + align-self: flex-start; + } + + .auth-older-toggle:hover { + background: var(--color-divine-surface); + color: var(--color-divine-text); + } + + .auth-older-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + border-top: 1px dashed var(--color-divine-border); + padding-top: 0.375rem; + } + + .auth-card-older { + opacity: 0.75; + } + + .auth-card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.375rem; + } + + .auth-label { + font-size: 0.825rem; + font-weight: 600; + color: var(--color-divine-text); + } + + .auth-meta { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.75rem; + row-gap: 0.2rem; + } + + .auth-meta-row { + display: contents; + } + + .auth-meta-label { + font-size: 0.7rem; + color: var(--color-divine-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .auth-meta-value { + font-size: 0.75rem; + color: var(--color-divine-text); + word-break: break-all; + } + + .auth-meta-value.relays { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--color-divine-text-secondary); + } + + /* ========================================================= + Synvya light admin variant (overrides when .synvya-admin) + ========================================================= */ + .support-page.synvya-admin { + max-width: 720px; + padding: 2rem 1.5rem 3rem; + background: transparent; + } + + :global(body:has(.support-page.synvya-admin)) { + background: #f7f9f8; + } + + .synvya-brand { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .synvya-brand-logo { + height: 26px; + } + + .synvya-brand-sub { + font-size: 0.85rem; + color: #5a6b6a; + font-weight: 500; + } + + /* Card surfaces → white with subtle border */ + .support-page.synvya-admin .admin-header, + .support-page.synvya-admin .status-card, + .support-page.synvya-admin .no-result, + .support-page.synvya-admin .user-list, + .support-page.synvya-admin .link-card, + .support-page.synvya-admin .results-banner, + .support-page.synvya-admin .search-input-wrap { + background: #ffffff; + border-color: #e2e8e6; + color: #1f2937; + } + + .support-page.synvya-admin .tools-section h2, + .support-page.synvya-admin .admin-label, + .support-page.synvya-admin .list-name, + .support-page.synvya-admin .field-value, + .support-page.synvya-admin .team-name, + .support-page.synvya-admin .restaurant-name, + .support-page.synvya-admin .auth-label, + .support-page.synvya-admin .section-title, + .support-page.synvya-admin .link-title, + .support-page.synvya-admin .claim-title, + .support-page.synvya-admin .auth-meta-value { + color: #0f1f1c; + } + + .support-page.synvya-admin .field-label, + .support-page.synvya-admin .restaurant-label, + .support-page.synvya-admin .auth-header { + color: #4b5e5a; + } + + .support-page.synvya-admin .search-hint, + .support-page.synvya-admin .list-username, + .support-page.synvya-admin .list-sessions, + .support-page.synvya-admin .team-joined, + .support-page.synvya-admin .auth-meta-label, + .support-page.synvya-admin .auth-scope-hint, + .support-page.synvya-admin .muted-text, + .support-page.synvya-admin .link-desc, + .support-page.synvya-admin .status-text, + .support-page.synvya-admin .status-neutral, + .support-page.synvya-admin .auth-meta-value.relays { + color: #7a8a86; + } + + .support-page.synvya-admin .search-input { + color: #0f1f1c; + } + + .support-page.synvya-admin .search-input::placeholder { + color: #9ba8a4; + } + + .support-page.synvya-admin .user-list-item { + border-bottom-color: #e9efed; + } + + .support-page.synvya-admin .user-list-item.expanded { + background: #f4f9f7; + } + + .support-page.synvya-admin .user-list-row:hover { + background: #eff5f3; + } + + .support-page.synvya-admin .user-card, + .support-page.synvya-admin .status-strip, + .support-page.synvya-admin .teams-section, + .support-page.synvya-admin .claim-section { + border-top-color: #e9efed; + } + + .support-page.synvya-admin .status-strip { + background: #f4f9f7; + } + + .support-page.synvya-admin .team-card, + .support-page.synvya-admin .auth-card, + .support-page.synvya-admin .auth-group { + background: #ffffff; + border-color: #e2e8e6; + } + + .support-page.synvya-admin .auth-sub-label, + .support-page.synvya-admin .auth-count-badge { + color: #4b5e5a; + } + + .support-page.synvya-admin .restaurant-block { + background: #f7fbf9; + border-color: #dbe7e3; + } + + .support-page.synvya-admin .claim-section { + background: #f1faf5; + } + + .support-page.synvya-admin .claim-url-input { + background: #ffffff; + border-color: #dbe7e3; + color: #0f1f1c; + } + + .support-page.synvya-admin .format-toggle { + background: #eef4f1; + border-color: #dbe7e3; + color: #4b5e5a; + } + + .support-page.synvya-admin .icon-btn { + color: #7a8a86; + } + .support-page.synvya-admin .icon-btn:hover { + color: var(--color-divine-green); + background: #eef4f1; + } diff --git a/web/src/routes/verify-email/+page.svelte b/web/src/routes/verify-email/+page.svelte index eb04749b..7c6dfc39 100644 --- a/web/src/routes/verify-email/+page.svelte +++ b/web/src/routes/verify-email/+page.svelte @@ -5,9 +5,13 @@ import { toast } from 'svelte-hot-french-toast'; import { KeycastApi } from '$lib/keycast_api.svelte'; import { BRAND } from '$lib/brand'; + import { getLoginUrl } from '$lib/utils/env'; + import { CheckCircle, XCircle, Warning, CircleNotch } from 'phosphor-svelte'; const api = new KeycastApi(); - + const loginUrl = getLoginUrl(); + const isSynvyaManaged = loginUrl !== '/login'; + const pageTitle = isSynvyaManaged ? 'Verify Email - Synvya' : `Verify Email - ${BRAND.name}`; let status = $state<'loading' | 'processing' | 'success' | 'oauth_redirect' | 'headless_verified' | 'error' | 'no-token'>('loading'); let message = $state(''); let redirectUrl = $state(''); @@ -142,33 +146,31 @@ - Verify Email - {BRAND.name} + {pageTitle} -
-
+
+
- - {BRAND.shortName} - Login + + {#if isSynvyaManaged} + Synvya + {:else} + {BRAND.shortName} + Login + {/if} {#if status === 'loading'}
- - - - + {#if isSynvyaManaged}{:else} {/if}

Verifying your email...

Please wait

{:else if status === 'processing'}
- - - - + {#if isSynvyaManaged}{:else} {/if}

Almost there...

{message}

@@ -176,9 +178,7 @@ {:else if status === 'oauth_redirect'}
- - - + {#if isSynvyaManaged}{:else} {/if}

Email Verified!

{message}

@@ -186,18 +186,14 @@ {:else if status === 'headless_verified'}
- - - + {#if isSynvyaManaged}{:else} {/if}

Email Verified!

{message}

{:else if status === 'success'}
- - - + {#if isSynvyaManaged}{:else} {/if}

Email Verified!

{message}

@@ -206,27 +202,23 @@ {:else if status === 'error'}
- - - + {#if isSynvyaManaged}{:else} {/if}

Verification Failed

{message}

{:else if status === 'no-token'}
- - - + {#if isSynvyaManaged}{:else} {/if}

Invalid Link

This verification link is invalid or incomplete.

{/if} @@ -243,6 +235,10 @@ background: var(--color-divine-bg); } + .synvya-page { + background: color-mix(in srgb, var(--color-divine-muted) 60%, white); + } + .verify-container { background: var(--color-divine-surface); border: 1px solid var(--color-divine-border); @@ -254,6 +250,15 @@ box-shadow: 0 2px 8px rgba(39, 197, 139, 0.08); } + .synvya-container { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none; + max-width: 24rem; + } + .verify-branding { display: inline-flex; flex-direction: column; @@ -267,6 +272,17 @@ opacity: 0.85; } + .synvya-branding { + flex-direction: column; + align-items: center; + gap: 0; + opacity: 1; + } + + .synvya-branding:hover { + opacity: 0.95; + } + .verify-logo-img { height: 28px; } @@ -281,6 +297,11 @@ opacity: 0.6; } + .synvya-logo-img { + height: 3rem; + width: auto; + } + .status-icon { display: flex; justify-content: center; @@ -316,12 +337,56 @@ font-weight: 700; } + .synvya-container h1 { + color: #0f172a; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.01em; + } + .subtitle { color: var(--color-divine-text-secondary); margin: 0 0 1.25rem 0; font-size: 0.95rem; } + .synvya-container .subtitle, + .synvya-container .redirect-notice, + .synvya-container .processing-notice { + color: #475569; + } + + /* Synvya-themed status icons: small chip with soft background instead of + a large filled-circle SVG. */ + .synvya-container .status-icon { + margin-bottom: 1rem; + } + + .synvya-container .status-icon :global(svg) { + padding: 0.45rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.04); + } + + .synvya-container .status-icon.success :global(svg) { + color: #16a34a; + background: rgba(22, 163, 74, 0.1); + } + + .synvya-container .status-icon.error :global(svg) { + color: #dc2626; + background: rgba(220, 38, 38, 0.08); + } + + .synvya-container .status-icon.loading :global(svg) { + color: #0f172a; + background: rgba(15, 23, 42, 0.04); + } + + .synvya-container .status-icon :global(.synvya-spin) { + animation: spin 1s linear infinite; + } + .redirect-notice { color: var(--color-divine-text-tertiary); font-size: 0.85rem; @@ -354,11 +419,20 @@ transition: all 0.2s; } + .synvya-container .btn-primary { + background: #0f172a; + box-shadow: none; + } + .btn-primary:hover { background: var(--color-divine-green-dark); box-shadow: 0 2px 8px rgba(39, 197, 139, 0.16); } + .synvya-container .btn-primary:hover { + background: #111827; + } + .btn-secondary { display: block; padding: 0.75rem 1.5rem; @@ -373,8 +447,19 @@ transition: all 0.2s; } + .synvya-container .btn-secondary { + background: rgba(255, 255, 255, 0.72); + border-color: rgba(15, 23, 42, 0.12); + color: #0f172a; + } + .btn-secondary:hover { background: var(--color-divine-muted); color: var(--color-divine-text); } + + .synvya-container .btn-secondary:hover { + background: rgba(248, 250, 252, 1); + border-color: rgba(15, 23, 42, 0.2); + } diff --git a/web/static/site.webmanifest b/web/static/site.webmanifest index b4dddbbd..962ebaf2 100644 --- a/web/static/site.webmanifest +++ b/web/static/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "diVine Login", - "short_name": "diVine", + "name": "Synvya Login", + "short_name": "Synvya", "icons": [ { "src": "/web-app-manifest-192x192.png", diff --git a/web/static/synvya-logo.png b/web/static/synvya-logo.png new file mode 100644 index 00000000..77c60336 Binary files /dev/null and b/web/static/synvya-logo.png differ diff --git a/web/static/synvya-logo.svg b/web/static/synvya-logo.svg new file mode 100644 index 00000000..61f3e292 --- /dev/null +++ b/web/static/synvya-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + Synvya +