feat(signal): add admin UI, frontend, and build changes

This commit is contained in:
Lewis
2026-03-22 07:14:20 +00:00
committed by Tangled
parent 7c55a5ceb9
commit 318818ad51
13 changed files with 173 additions and 36 deletions
+8
View File
@@ -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"
+2 -2
View File
@@ -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"
Generated
+8
View File
@@ -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",
]
+1
View File
@@ -141,3 +141,4 @@ curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag
lto = "fat"
strip = true
codegen-units = 1
panic = "abort"
+4 -13
View File
@@ -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
@@ -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
}
+2 -5
View File
@@ -73,9 +73,7 @@ struct PermissionEntry {
aud: Option<String>,
}
pub async fn expand_include_scopes(
scope_string: &str,
) -> Result<String, ScopeExpansionError> {
pub async fn expand_include_scopes(scope_string: &str) -> Result<String, ScopeExpansionError> {
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());
}
@@ -65,8 +65,27 @@
let logoPreview = $state<string | null>(null)
let serverConfigLoading = $state(false)
let signalEnabled = $state(false)
let signalLinked = $state(false)
let signalQr = $state<string | null>(null)
let signalLoading = $state(false)
let signalPollTimer: ReturnType<typeof setInterval> | null = null
let signalLinkTimeout: ReturnType<typeof setTimeout> | 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 @@
</form>
</section>
{#if signalEnabled}
<section class="config-section">
<div class="section-header-row">
<h3>{$_('admin.signalIntegration')}</h3>
{#if signalLinked}
<span class="badge verified">{$_('admin.signalLinked')}</span>
{:else if !signalQr}
<span class="badge unverified">{$_('admin.signalNotLinked')}</span>
{/if}
</div>
{#if signalQr}
<div class="qr-container">
<p>{$_('admin.signalLinking')}</p>
<img src="data:image/png;base64,{signalQr}" alt="Signal QR" class="qr-code" />
</div>
{:else if signalLinked}
<button type="button" class="danger sm" onclick={unlinkSignal} disabled={signalLoading}>
{$_('admin.signalUnlinkDevice')}
</button>
{:else}
<button type="button" onclick={linkSignal} disabled={signalLoading}>
{signalLoading ? $_('common.loading') : $_('admin.signalLinkDevice')}
</button>
{/if}
</section>
{/if}
<section class="stats-section">
<div class="section-header-row">
<h3>{$_('admin.serverStats')}</h3>
+14
View File
@@ -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<SignalStatus> {
return xrpc("_admin.getSignalStatus", { token });
},
linkSignalDevice(token: AccessToken): Promise<SignalLinkResult> {
return xrpc("_admin.linkSignalDevice", { method: "POST", token });
},
unlinkSignalDevice(token: AccessToken): Promise<void> {
return xrpc("_admin.unlinkSignalDevice", { method: "POST", token });
},
getServerConfig(): Promise<ServerConfig> {
return xrpc("_server.getConfig");
},
+9
View File
@@ -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;
+14 -1
View File
@@ -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": {
+2 -1
View File
@@ -644,7 +644,8 @@ code.record {
.qr-container {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
margin: var(--space-4) 0;
}
-8
View File
@@ -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.";
};
};
};
};