From 318818ad51f2be8fc7d56f851a052923a4861568 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sat, 21 Mar 2026 13:17:48 +0200 Subject: [PATCH] feat(signal): add admin UI, frontend, and build changes --- .config/nextest.toml | 8 ++ .tangled/workflows/publish-image.yml | 4 +- Cargo.lock | 8 ++ Cargo.toml | 1 + Dockerfile | 17 +-- .../src/endpoints/token/grants.rs | 8 +- crates/tranquil-scopes/src/permission_set.rs | 7 +- .../components/dashboard/AdminContent.svelte | 107 +++++++++++++++++- frontend/src/lib/api.ts | 14 +++ frontend/src/lib/types/api.ts | 9 ++ frontend/src/locales/en.json | 15 ++- frontend/src/styles/dashboard.css | 3 +- module.nix | 8 -- 13 files changed, 173 insertions(+), 36 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 0ca5cae..8826cc8 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -29,6 +29,10 @@ test-group = "serial-env-tests" filter = "binary(ripple_cluster)" test-group = "serial-env-tests" +[[profile.default.overrides]] +filter = "package(tranquil-signal)" +test-group = "serial-env-tests" + [[profile.default.overrides]] filter = "binary(whole_story)" test-group = "heavy-load-tests" @@ -53,6 +57,10 @@ test-group = "serial-env-tests" filter = "binary(ripple_cluster)" test-group = "serial-env-tests" +[[profile.ci.overrides]] +filter = "package(tranquil-signal)" +test-group = "serial-env-tests" + [[profile.ci.overrides]] filter = "binary(whole_story)" test-group = "heavy-load-tests" diff --git a/.tangled/workflows/publish-image.yml b/.tangled/workflows/publish-image.yml index a05626a..ce0c33b 100644 --- a/.tangled/workflows/publish-image.yml +++ b/.tangled/workflows/publish-image.yml @@ -20,5 +20,5 @@ steps: - name: Publish image command: | - podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil-pds/tranquil:latest" - podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil-pds/tranquil:$TANGLED_COMMIT_SHA" + podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest" + podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" diff --git a/Cargo.lock b/Cargo.lock index 4eab9c3..168e565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7073,6 +7073,7 @@ dependencies = [ "futures", "hex", "http 1.4.0", + "image", "infer", "ipld-core", "jacquard-common", @@ -7080,6 +7081,7 @@ dependencies = [ "k256", "multibase", "multihash", + "qrcodegen", "rand 0.8.5", "reqwest", "serde", @@ -7096,6 +7098,7 @@ dependencies = [ "tranquil-lexicon", "tranquil-pds", "tranquil-scopes", + "tranquil-signal", "tranquil-types", "urlencoding", "uuid", @@ -7148,10 +7151,13 @@ dependencies = [ "base64 0.22.1", "reqwest", "serde_json", + "sqlx", "thiserror 2.0.17", "tokio", + "tracing", "tranquil-config", "tranquil-db-traits", + "tranquil-signal", "uuid", ] @@ -7375,6 +7381,7 @@ dependencies = [ "tranquil-repo", "tranquil-ripple", "tranquil-scopes", + "tranquil-signal", "tranquil-storage", "tranquil-sync", "tranquil-types", @@ -7455,6 +7462,7 @@ dependencies = [ "tranquil-config", "tranquil-oauth-server", "tranquil-pds", + "tranquil-signal", "tranquil-sync", ] diff --git a/Cargo.toml b/Cargo.toml index 5ffd39f..e38a76b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,3 +141,4 @@ curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag lto = "fat" strip = true codegen-units = 1 +panic = "abort" diff --git a/Dockerfile b/Dockerfile index f6e4479..29b2426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY frontend/ ./ RUN deno task build FROM rust:1.92-alpine AS builder -RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang +RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" WORKDIR /app ARG SLIM="false" @@ -29,6 +29,7 @@ COPY crates/tranquil-pds ./crates/tranquil-pds COPY crates/tranquil-sync ./crates/tranquil-sync COPY crates/tranquil-api ./crates/tranquil-api COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server +COPY crates/tranquil-signal ./crates/tranquil-signal COPY crates/tranquil-server ./crates/tranquil-server COPY migrations ./crates/tranquil-pds/migrations RUN --mount=type=cache,target=/usr/local/cargo/registry \ @@ -40,23 +41,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ fi && \ cp target/release/tranquil-server /tmp/tranquil-pds -FROM alpine:3.23 AS signal-cli -RUN apk add --no-cache curl tar -ARG SIGNAL_CLI_VERSION=0.13.24 -RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ - | tar xz -C /usr/local/bin - -FROM debian:trixie-slim -RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \ - && rm -rf /var/lib/apt/lists/* \ +FROM alpine:3.23 +RUN apk add --no-cache msmtp ca-certificates \ && ln -sf /usr/bin/msmtp /usr/sbin/sendmail -COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli -VOLUME /var/lib/signal-cli COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend COPY migrations /app/migrations WORKDIR /app -ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli ENV SERVER_HOST=0.0.0.0 ENV SERVER_PORT=3000 EXPOSE 3000 diff --git a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs index 82d790a..1b3d693 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs @@ -155,11 +155,9 @@ pub async fn handle_authorization_code_grant( let final_scope = if let Some(ref scope) = raw_scope { if scope.contains("include:") { - Some( - expand_include_scopes(scope) - .await - .map_err(|e| OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")))?, - ) + Some(expand_include_scopes(scope).await.map_err(|e| { + OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")) + })?) } else { raw_scope } diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index a103e8b..4c99c56 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -73,9 +73,7 @@ struct PermissionEntry { aud: Option, } -pub async fn expand_include_scopes( - scope_string: &str, -) -> Result { +pub async fn expand_include_scopes(scope_string: &str) -> Result { let futures: Vec<_> = scope_string .split_whitespace() .map(|scope| async move { @@ -571,8 +569,7 @@ mod tests { #[tokio::test] async fn test_expand_include_scopes_fails_on_unresolvable_nsid() { - let result = - expand_include_scopes("atproto include:nonexistent.fake.permissionSet").await; + let result = expand_include_scopes("atproto include:nonexistent.fake.permissionSet").await; assert!(result.is_err()); } diff --git a/frontend/src/components/dashboard/AdminContent.svelte b/frontend/src/components/dashboard/AdminContent.svelte index dac667b..eb6f139 100644 --- a/frontend/src/components/dashboard/AdminContent.svelte +++ b/frontend/src/components/dashboard/AdminContent.svelte @@ -65,8 +65,27 @@ let logoPreview = $state(null) let serverConfigLoading = $state(false) + let signalEnabled = $state(false) + let signalLinked = $state(false) + let signalQr = $state(null) + let signalLoading = $state(false) + let signalPollTimer: ReturnType | null = null + let signalLinkTimeout: ReturnType | null = null + + function stopSignalPolling() { + if (signalPollTimer) { + clearInterval(signalPollTimer) + signalPollTimer = null + } + if (signalLinkTimeout) { + clearTimeout(signalLinkTimeout) + signalLinkTimeout = null + } + } + onMount(async () => { - await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)]) + await Promise.all([loadStats(), loadServerConfig(), loadUsers(true), loadSignalStatus()]) + return () => stopSignalPolling() }) async function loadStats() { @@ -210,6 +229,64 @@ logoChanged } + let signalPollErrors = $state(0) + + async function loadSignalStatus() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return + try { + const status = await api.getSignalStatus(session.accessJwt) + signalEnabled = status.enabled + signalLinked = status.linked + signalPollErrors = 0 + if (signalLinked && signalQr) { + signalQr = null + stopSignalPolling() + toast.success($_('admin.signalLinkSuccess')) + } + } catch (e) { + signalPollErrors += 1 + if (signalPollErrors >= 3 && signalQr) { + stopSignalPolling() + signalQr = null + toast.error(e instanceof ApiError ? e.message : $_('admin.signalFailedToLoad')) + } + } + } + + async function linkSignal() { + signalLoading = true + try { + const result = await api.linkSignalDevice(session.accessJwt) + signalQr = result.qrBase64 + signalPollTimer = setInterval(() => loadSignalStatus(), 2000) + signalLinkTimeout = setTimeout(() => { + if (!signalLinked) { + signalQr = null + stopSignalPolling() + toast.error($_('admin.signalLinkTimedOut')) + } + }, 130_000) + } catch (e) { + toast.error(e instanceof ApiError ? e.message : $_('admin.signalLinkFailed')) + } finally { + signalLoading = false + } + } + + async function unlinkSignal() { + if (!confirm($_('admin.signalUnlinkConfirm'))) return + signalLoading = true + try { + await api.unlinkSignalDevice(session.accessJwt) + signalLinked = false + toast.success($_('admin.signalUnlinkSuccess')) + } catch (e) { + toast.error(e instanceof ApiError ? e.message : $_('admin.signalUnlinkFailed')) + } finally { + signalLoading = false + } + } + async function showUserDetail(user: User) { selectedUser = user userDetailLoading = true @@ -323,6 +400,34 @@ + {#if signalEnabled} +
+
+

{$_('admin.signalIntegration')}

+ {#if signalLinked} + {$_('admin.signalLinked')} + {:else if !signalQr} + {$_('admin.signalNotLinked')} + {/if} +
+ + {#if signalQr} +
+

{$_('admin.signalLinking')}

+ Signal QR +
+ {:else if signalLinked} + + {:else} + + {/if} +
+ {/if} +

{$_('admin.serverStats')}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca5f1cb..73de588 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -69,6 +69,8 @@ import type { ServerConfig, ServerDescription, ServerStats, + SignalLinkResult, + SignalStatus, Session, SsoLinkedAccount, StartPasskeyRegistrationResponse, @@ -709,6 +711,18 @@ export const api = { return xrpc("_admin.getServerStats", { token }); }, + getSignalStatus(token: AccessToken): Promise { + return xrpc("_admin.getSignalStatus", { token }); + }, + + linkSignalDevice(token: AccessToken): Promise { + return xrpc("_admin.linkSignalDevice", { method: "POST", token }); + }, + + unlinkSignalDevice(token: AccessToken): Promise { + return xrpc("_admin.unlinkSignalDevice", { method: "POST", token }); + }, + getServerConfig(): Promise { return xrpc("_server.getConfig"); }, diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 6fc499e..f91adcb 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -248,6 +248,15 @@ export interface ServerStats { blobStorageBytes: number; } +export interface SignalStatus { + enabled: boolean; + linked: boolean; +} + +export interface SignalLinkResult { + qrBase64: string; +} + export interface ServerConfig { serverName: string; primaryColor: string | null; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 766113e..f5b5aa7 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -490,7 +490,20 @@ "invitesEnabled": "User invites enabled", "invitesDisabled": "User invites disabled", "userDeleted": "User account deleted", - "failedToLoadConfig": "Failed to load server configuration" + "failedToLoadConfig": "Failed to load server configuration", + "signalIntegration": "Signal", + "signalLinked": "Linked", + "signalNotLinked": "Not linked", + "signalLinkDevice": "Link Device", + "signalUnlinkDevice": "Unlink", + "signalLinking": "Scan with Signal", + "signalLinkSuccess": "Signal device linked", + "signalUnlinkSuccess": "Signal device unlinked", + "signalUnlinkConfirm": "Unlink Signal device? Notifications will stop.", + "signalLinkFailed": "Failed to link Signal device", + "signalLinkTimedOut": "Signal linking timed out", + "signalUnlinkFailed": "Failed to unlink Signal device", + "signalFailedToLoad": "Failed to load Signal status" }, "oauth": { "login": { diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css index c4eb8cc..e7fd497 100644 --- a/frontend/src/styles/dashboard.css +++ b/frontend/src/styles/dashboard.css @@ -644,7 +644,8 @@ code.record { .qr-container { display: flex; - justify-content: center; + flex-direction: column; + align-items: center; margin: var(--space-4) 0; } diff --git a/module.nix b/module.nix index 6a34a90..e3bb67a 100644 --- a/module.nix +++ b/module.nix @@ -123,14 +123,6 @@ in { description = "Path to the sendmail executable to use for sending emails."; }; }; - - signal = { - cli_path = mkOption { - type = types.path; - default = lib.getExe pkgs.signal-cli; - description = "Path to the signal-cli executable to use for sending Signal notifications."; - }; - }; }; };