mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 01:04:14 +00:00
feat(signal): add admin UI, frontend, and build changes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -644,7 +644,8 @@ code.record {
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user