Generic cosmetic per-server config

This commit is contained in:
lewis
2025-12-23 20:35:17 +02:00
parent ac792fdd75
commit 11fc081971
27 changed files with 1202 additions and 46 deletions

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage_key FROM blobs WHERE cid = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "storage_key",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM blobs WHERE cid = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12"
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getCurrentPath, navigate } from './lib/router.svelte'
import { initAuth, getAuthState } from './lib/auth.svelte'
import { initServerConfig } from './lib/serverConfig.svelte'
import { initI18n, _ } from './lib/i18n'
import { isLoading as i18nLoading } from 'svelte-i18n'
import Login from './routes/Login.svelte'
@@ -41,6 +42,7 @@
}
$effect(() => {
initServerConfig()
initAuth().then(({ oauthLoginCompleted }) => {
if (oauthLoginCompleted) {
navigate('/dashboard')

View File

@@ -265,10 +265,20 @@ export const api = {
availableUserDomains: string[]
inviteCodeRequired: boolean
links?: { privacyPolicy?: string; termsOfService?: string }
version?: string
}> {
return xrpc('com.atproto.server.describeServer')
},
async listRepos(limit?: number): Promise<{
repos: Array<{ did: string; head: string; rev: string }>
cursor?: string
}> {
const params: Record<string, string> = {}
if (limit) params.limit = String(limit)
return xrpc('com.atproto.sync.listRepos', { params })
},
async getNotificationPrefs(token: string): Promise<{
preferredChannel: string
email: string
@@ -325,6 +335,51 @@ export const api = {
return xrpc('com.tranquil.admin.getServerStats', { token })
},
async getServerConfig(): Promise<{
serverName: string
primaryColor: string | null
primaryColorDark: string | null
secondaryColor: string | null
secondaryColorDark: string | null
logoCid: string | null
}> {
return xrpc('com.tranquil.server.getConfig')
},
async updateServerConfig(
token: string,
config: {
serverName?: string
primaryColor?: string
primaryColorDark?: string
secondaryColor?: string
secondaryColorDark?: string
logoCid?: string
}
): Promise<{ success: boolean }> {
return xrpc('com.tranquil.admin.updateServerConfig', {
method: 'POST',
token,
body: config,
})
},
async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> {
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': file.type,
},
body: file,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
throw new ApiError(res.status, err.error, err.message)
}
return res.json()
},
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
await xrpc('com.tranquil.account.changePassword', {
method: 'POST',

View File

@@ -75,7 +75,13 @@ export async function startOAuthLogin(): Promise<void> {
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'atproto transition:generic',
scope: [
'atproto',
'repo:*?action=create',
'repo:*?action=update',
'repo:*?action=delete',
'blob:*/*',
].join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',

View File

@@ -0,0 +1,123 @@
import { api } from './api'
interface ServerConfigState {
serverName: string | null
primaryColor: string | null
primaryColorDark: string | null
secondaryColor: string | null
secondaryColorDark: string | null
hasLogo: boolean
loading: boolean
}
let state = $state<ServerConfigState>({
serverName: null,
primaryColor: null,
primaryColorDark: null,
secondaryColor: null,
secondaryColorDark: null,
hasLogo: false,
loading: true,
})
let initialized = false
let darkModeQuery: MediaQueryList | null = null
function isDarkMode(): boolean {
return darkModeQuery?.matches ?? false
}
function applyColors() {
const root = document.documentElement
const dark = isDarkMode()
if (dark) {
if (state.primaryColorDark) {
root.style.setProperty('--accent', state.primaryColorDark)
} else {
root.style.removeProperty('--accent')
}
if (state.secondaryColorDark) {
root.style.setProperty('--secondary', state.secondaryColorDark)
} else {
root.style.removeProperty('--secondary')
}
} else {
if (state.primaryColor) {
root.style.setProperty('--accent', state.primaryColor)
} else {
root.style.removeProperty('--accent')
}
if (state.secondaryColor) {
root.style.setProperty('--secondary', state.secondaryColor)
} else {
root.style.removeProperty('--secondary')
}
}
}
function setFavicon(hasLogo: boolean) {
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
if (hasLogo) {
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = '/logo'
} else if (link) {
link.remove()
}
}
export async function initServerConfig(): Promise<void> {
if (initialized) return
initialized = true
darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
darkModeQuery.addEventListener('change', applyColors)
try {
const config = await api.getServerConfig()
state.serverName = config.serverName
state.primaryColor = config.primaryColor
state.primaryColorDark = config.primaryColorDark
state.secondaryColor = config.secondaryColor
state.secondaryColorDark = config.secondaryColorDark
state.hasLogo = !!config.logoCid
document.title = config.serverName
applyColors()
setFavicon(state.hasLogo)
} catch {
state.serverName = null
} finally {
state.loading = false
}
}
export function getServerConfigState() {
return state
}
export function setServerName(name: string) {
state.serverName = name
document.title = name
}
export function setColors(colors: {
primaryColor?: string | null
primaryColorDark?: string | null
secondaryColor?: string | null
secondaryColorDark?: string | null
}) {
if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor
if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark
if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor
if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark
applyColors()
}
export function setHasLogo(hasLogo: boolean) {
state.hasLogo = hasLogo
setFavicon(hasLogo)
}

View File

@@ -14,7 +14,9 @@
"expires": "Expires",
"name": "Name",
"dashboard": "Dashboard",
"backToDashboard": "← Dashboard"
"backToDashboard": "← Dashboard",
"copied": "Copied!",
"copyToClipboard": "Copy to Clipboard"
},
"login": {
"title": "Sign In",
@@ -45,6 +47,9 @@
"register": {
"title": "Create Account",
"subtitle": "Create a new account on this PDS",
"migrateTitle": "Already have a Bluesky account?",
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
"migrateLink": "Migrate with PDS Moover",
"handle": "Handle",
"handlePlaceholder": "yourname",
"handleHint": "Your full handle will be: @{handle}",
@@ -226,7 +231,10 @@
"revoke": "Revoke",
"revoking": "Revoking...",
"creating": "Creating...",
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account."
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
"saveWarningTitle": "Important: Save this app password!",
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
"acknowledgeLabel": "I have saved my app password in a secure location"
},
"sessions": {
"title": "Active Sessions",

View File

@@ -14,7 +14,9 @@
"expires": "Vanhenee",
"name": "Nimi",
"dashboard": "Hallintapaneeli",
"backToDashboard": "← Hallintapaneeli"
"backToDashboard": "← Hallintapaneeli",
"copied": "Kopioitu!",
"copyToClipboard": "Kopioi"
},
"login": {
"title": "Kirjaudu sisään",
@@ -45,6 +47,9 @@
"register": {
"title": "Luo tili",
"subtitle": "Luo uusi tili tälle PDS:lle",
"migrateTitle": "Onko sinulla jo Bluesky-tili?",
"migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
"migrateLink": "Siirrä PDS Mooverilla",
"handle": "Käyttäjänimi",
"handlePlaceholder": "nimesi",
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
@@ -226,7 +231,10 @@
"revoke": "Peruuta",
"revoking": "Peruutetaan...",
"creating": "Luodaan...",
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi."
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
"acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan"
},
"sessions": {
"title": "Aktiiviset istunnot",

View File

@@ -14,7 +14,9 @@
"expires": "有効期限",
"name": "名前",
"dashboard": "ダッシュボード",
"backToDashboard": "← ダッシュボード"
"backToDashboard": "← ダッシュボード",
"copied": "コピーしました!",
"copyToClipboard": "クリップボードにコピー"
},
"login": {
"title": "サインイン",
@@ -45,6 +47,9 @@
"register": {
"title": "アカウント作成",
"subtitle": "この PDS で新規アカウントを作成",
"migrateTitle": "すでにBlueskyアカウントをお持ちですか",
"migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。",
"migrateLink": "PDS Mooverで移行する",
"handle": "ハンドル",
"handlePlaceholder": "あなたの名前",
"handleHint": "完全なハンドル: @{handle}",
@@ -226,7 +231,10 @@
"revoke": "取り消す",
"revoking": "取り消し中...",
"creating": "作成中...",
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。"
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
"acknowledgeLabel": "アプリパスワードを安全な場所に保存しました"
},
"sessions": {
"title": "アクティブセッション",

View File

@@ -14,7 +14,9 @@
"expires": "만료일",
"name": "이름",
"dashboard": "대시보드",
"backToDashboard": "← 대시보드"
"backToDashboard": "← 대시보드",
"copied": "복사됨!",
"copyToClipboard": "클립보드에 복사"
},
"login": {
"title": "로그인",
@@ -45,6 +47,9 @@
"register": {
"title": "계정 만들기",
"subtitle": "이 PDS에 새 계정을 만듭니다",
"migrateTitle": "이미 Bluesky 계정이 있으신가요?",
"migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
"migrateLink": "PDS Moover로 마이그레이션",
"handle": "핸들",
"handlePlaceholder": "사용자 이름",
"handleHint": "전체 핸들: @{handle}",
@@ -226,7 +231,10 @@
"revoke": "취소",
"revoking": "취소 중...",
"creating": "생성 중...",
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다."
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
"acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다"
},
"sessions": {
"title": "활성 세션",

View File

@@ -14,7 +14,9 @@
"expires": "Upphör",
"name": "Namn",
"dashboard": "Kontrollpanel",
"backToDashboard": "← Kontrollpanel"
"backToDashboard": "← Kontrollpanel",
"copied": "Kopierat!",
"copyToClipboard": "Kopiera"
},
"login": {
"title": "Logga in",
@@ -45,6 +47,9 @@
"register": {
"title": "Skapa konto",
"subtitle": "Skapa ett nytt konto på denna PDS",
"migrateTitle": "Har du redan ett Bluesky-konto?",
"migrateDescription": "Du kan flytta ditt befintliga konto till denna PDS istället för att skapa ett nytt. Dina följare, inlägg och identitet följer med.",
"migrateLink": "Flytta med PDS Moover",
"handle": "Användarnamn",
"handlePlaceholder": "dittnamn",
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
@@ -226,7 +231,10 @@
"revoke": "Återkalla",
"revoking": "Återkallar...",
"creating": "Skapar...",
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto."
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
"acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats"
},
"sessions": {
"title": "Aktiva sessioner",

View File

@@ -14,7 +14,9 @@
"expires": "过期时间",
"name": "名称",
"dashboard": "控制台",
"backToDashboard": "← 返回控制台"
"backToDashboard": "← 返回控制台",
"copied": "已复制!",
"copyToClipboard": "复制"
},
"login": {
"title": "登录",
@@ -45,6 +47,9 @@
"register": {
"title": "创建账户",
"subtitle": "在此 PDS 上创建新账户",
"migrateTitle": "已有 Bluesky 账户?",
"migrateDescription": "您可以将现有账户迁移到此 PDS而无需创建新账户。您的关注者、帖子和身份都会一起迁移。",
"migrateLink": "使用 PDS Moover 迁移",
"handle": "用户名",
"handlePlaceholder": "您的用户名",
"handleHint": "您的完整用户名将是:@{handle}",
@@ -226,7 +231,10 @@
"revoke": "撤销",
"revoking": "撤销中...",
"creating": "创建中...",
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。"
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
"saveWarningTitle": "重要:请保存此应用专用密码!",
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
"acknowledgeLabel": "我已将应用专用密码保存在安全的地方"
},
"sessions": {
"title": "登录会话",

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
@@ -50,6 +51,23 @@
} | null>(null)
let userDetailLoading = $state(false)
let userActionLoading = $state(false)
let serverName = $state('')
let serverNameInput = $state('')
let primaryColor = $state('')
let primaryColorInput = $state('')
let primaryColorDark = $state('')
let primaryColorDarkInput = $state('')
let secondaryColor = $state('')
let secondaryColorInput = $state('')
let secondaryColorDark = $state('')
let secondaryColorDarkInput = $state('')
let logoCid = $state<string | null>(null)
let originalLogoCid = $state<string | null>(null)
let logoFile = $state<File | null>(null)
let logoPreview = $state<string | null>(null)
let serverConfigLoading = $state(false)
let serverConfigError = $state<string | null>(null)
let serverConfigSuccess = $state(false)
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
@@ -60,8 +78,100 @@
$effect(() => {
if (auth.session?.isAdmin) {
loadStats()
loadServerConfig()
}
})
async function loadServerConfig() {
try {
const config = await api.getServerConfig()
serverName = config.serverName
serverNameInput = config.serverName
primaryColor = config.primaryColor || ''
primaryColorInput = config.primaryColor || ''
primaryColorDark = config.primaryColorDark || ''
primaryColorDarkInput = config.primaryColorDark || ''
secondaryColor = config.secondaryColor || ''
secondaryColorInput = config.secondaryColor || ''
secondaryColorDark = config.secondaryColorDark || ''
secondaryColorDarkInput = config.secondaryColorDark || ''
logoCid = config.logoCid
originalLogoCid = config.logoCid
if (config.logoCid) {
logoPreview = '/logo'
}
} catch (e) {
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
}
}
async function saveServerConfig(e: Event) {
e.preventDefault()
if (!auth.session) return
serverConfigLoading = true
serverConfigError = null
serverConfigSuccess = false
try {
let newLogoCid = logoCid
if (logoFile) {
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
newLogoCid = result.blob.ref.$link
}
await api.updateServerConfig(auth.session.accessJwt, {
serverName: serverNameInput,
primaryColor: primaryColorInput,
primaryColorDark: primaryColorDarkInput,
secondaryColor: secondaryColorInput,
secondaryColorDark: secondaryColorDarkInput,
logoCid: newLogoCid ?? '',
})
serverName = serverNameInput
primaryColor = primaryColorInput
primaryColorDark = primaryColorDarkInput
secondaryColor = secondaryColorInput
secondaryColorDark = secondaryColorDarkInput
logoCid = newLogoCid
originalLogoCid = newLogoCid
logoFile = null
setGlobalServerName(serverNameInput)
setGlobalColors({
primaryColor: primaryColorInput || null,
primaryColorDark: primaryColorDarkInput || null,
secondaryColor: secondaryColorInput || null,
secondaryColorDark: secondaryColorDarkInput || null,
})
setGlobalHasLogo(!!newLogoCid)
serverConfigSuccess = true
setTimeout(() => { serverConfigSuccess = false }, 3000)
} catch (e) {
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
} finally {
serverConfigLoading = false
}
}
function handleLogoChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
logoFile = file
logoPreview = URL.createObjectURL(file)
}
}
function removeLogo() {
logoFile = null
logoCid = null
logoPreview = null
}
function hasConfigChanges(): boolean {
const logoChanged = logoFile !== null || logoCid !== originalLogoCid
return serverNameInput !== serverName ||
primaryColorInput !== primaryColor ||
primaryColorDarkInput !== primaryColorDark ||
secondaryColorInput !== secondaryColor ||
secondaryColorDarkInput !== secondaryColorDark ||
logoChanged
}
async function loadStats() {
if (!auth.session) return
loading = true
@@ -201,6 +311,128 @@
{#if error}
<div class="message error">{error}</div>
{/if}
<section>
<h2>Server Configuration</h2>
<form class="config-form" onsubmit={saveServerConfig}>
<div class="form-group">
<label for="serverName">Server Name</label>
<input
type="text"
id="serverName"
bind:value={serverNameInput}
placeholder="My PDS"
maxlength="100"
disabled={serverConfigLoading}
/>
<span class="help-text">Displayed in the browser tab and other places</span>
</div>
<div class="form-group">
<label for="serverLogo">Server Logo</label>
<div class="logo-upload">
{#if logoPreview}
<div class="logo-preview">
<img src={logoPreview} alt="Logo preview" />
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button>
</div>
{:else}
<input
type="file"
id="serverLogo"
accept="image/*"
onchange={handleLogoChange}
disabled={serverConfigLoading}
/>
{/if}
</div>
<span class="help-text">Used as favicon and shown in the navbar</span>
</div>
<h3 class="subsection-title">Theme Colors</h3>
<p class="theme-hint">Leave blank to use default colors.</p>
<div class="color-grid">
<div class="color-group">
<label for="primaryColor">Primary (Light Mode)</label>
<div class="color-input-row">
<input
type="color"
bind:value={primaryColorInput}
disabled={serverConfigLoading}
/>
<input
type="text"
id="primaryColor"
bind:value={primaryColorInput}
placeholder="#2c00ff (default)"
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="primaryColorDark">Primary (Dark Mode)</label>
<div class="color-input-row">
<input
type="color"
bind:value={primaryColorDarkInput}
disabled={serverConfigLoading}
/>
<input
type="text"
id="primaryColorDark"
bind:value={primaryColorDarkInput}
placeholder="#7b6bff (default)"
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="secondaryColor">Secondary (Light Mode)</label>
<div class="color-input-row">
<input
type="color"
bind:value={secondaryColorInput}
disabled={serverConfigLoading}
/>
<input
type="text"
id="secondaryColor"
bind:value={secondaryColorInput}
placeholder="#ff2400 (default)"
disabled={serverConfigLoading}
/>
</div>
</div>
<div class="color-group">
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
<div class="color-input-row">
<input
type="color"
bind:value={secondaryColorDarkInput}
disabled={serverConfigLoading}
/>
<input
type="text"
id="secondaryColorDark"
bind:value={secondaryColorDarkInput}
placeholder="#ff6b5b (default)"
disabled={serverConfigLoading}
/>
</div>
</div>
</div>
{#if serverConfigError}
<div class="message error">{serverConfigError}</div>
{/if}
{#if serverConfigSuccess}
<div class="message success">Server configuration saved</div>
{/if}
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
</button>
</form>
</section>
{#if stats}
<section>
<h2>Server Statistics</h2>
@@ -455,6 +687,139 @@
color: var(--error-text);
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.config-form {
max-width: 500px;
}
.form-group {
margin-bottom: var(--space-4);
}
.form-group label {
display: block;
font-weight: var(--font-medium);
margin-bottom: var(--space-2);
font-size: var(--text-sm);
}
.form-group input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--text-sm);
background: var(--bg-input);
color: var(--text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.help-text {
display: block;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: var(--space-1);
}
.config-form button {
padding: var(--space-2) var(--space-4);
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-sm);
}
.config-form button:hover:not(:disabled) {
background: var(--accent-hover);
}
.config-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.subsection-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: var(--space-5) 0 var(--space-2) 0;
padding-top: var(--space-4);
border-top: 1px solid var(--border-color);
}
.theme-hint {
font-size: var(--text-xs);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.color-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
@media (max-width: 500px) {
.color-grid {
grid-template-columns: 1fr;
}
}
.color-group label {
display: block;
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.color-group input[type="text"] {
width: 100%;
}
.logo-upload {
margin-top: var(--space-2);
}
.logo-preview {
display: flex;
align-items: center;
gap: var(--space-3);
}
.logo-preview img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-input);
}
.remove-logo {
background: transparent;
color: var(--error-text);
border: 1px solid var(--error-border);
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
.remove-logo:hover:not(:disabled) {
background: var(--error-bg);
}
section {
background: var(--bg-secondary);
padding: var(--space-6);

View File

@@ -11,6 +11,8 @@
let newPasswordName = $state('')
let creating = $state(false)
let createdPassword = $state<{ name: string; password: string } | null>(null)
let passwordCopied = $state(false)
let passwordAcknowledged = $state(false)
let revoking = $state<string | null>(null)
$effect(() => {
if (!auth.loading && !auth.session) {
@@ -67,8 +69,16 @@
revoking = null
}
}
function copyPassword() {
if (createdPassword) {
navigator.clipboard.writeText(createdPassword.password)
passwordCopied = true
}
}
function dismissCreated() {
createdPassword = null
passwordCopied = false
passwordAcknowledged = false
}
</script>
<div class="page">
@@ -84,13 +94,22 @@
{/if}
{#if createdPassword}
<div class="created-password">
<h3>{$_('appPasswords.created')}</h3>
<p>{$_('appPasswords.createdMessage')}</p>
<div class="password-display">
<code>{createdPassword.password}</code>
<div class="warning-box">
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
<p>{$_('appPasswords.saveWarningMessage')}</p>
</div>
<p class="password-name">{$_('common.name')}: {createdPassword.name}</p>
<button onclick={dismissCreated}>{$_('common.done')}</button>
<div class="password-display">
<div class="password-label">{$_('common.name')}: <strong>{createdPassword.name}</strong></div>
<code class="password-code">{createdPassword.password}</code>
<button type="button" class="copy-btn" onclick={copyPassword}>
{passwordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
</button>
</div>
<label class="checkbox-label">
<input type="checkbox" bind:checked={passwordAcknowledged} />
<span>{$_('appPasswords.acknowledgeLabel')}</span>
</label>
<button onclick={dismissCreated} disabled={!passwordAcknowledged}>{$_('common.done')}</button>
</div>
{/if}
<section class="create-section">
@@ -175,35 +194,78 @@
}
.created-password {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
background: var(--success-bg);
border: 1px solid var(--success-border);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
margin-bottom: var(--space-7);
}
.created-password h3 {
margin: 0 0 var(--space-2) 0;
color: var(--success-text);
.warning-box {
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-2);
color: var(--warning-text);
}
.warning-box p {
margin: 0;
color: var(--warning-text);
}
.password-display {
background: var(--bg-card);
padding: var(--space-4);
border-radius: var(--radius-md);
margin: var(--space-4) 0;
border: 2px solid var(--accent);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
}
.password-display code {
.password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.password-code {
display: block;
font-size: var(--text-xl);
font-family: ui-monospace, monospace;
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
user-select: all;
word-break: break-all;
}
.password-name {
color: var(--text-secondary);
.copy-btn {
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
section {

View File

@@ -2,11 +2,31 @@
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { getAuthState } from '../lib/auth.svelte'
import { getServerConfigState } from '../lib/serverConfig.svelte'
import { api } from '../lib/api'
const auth = getAuthState()
const serverConfig = getServerConfigState()
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
let pdsHostname = $state<string | null>(null)
let pdsVersion = $state<string | null>(null)
let userCount = $state<number | null>(null)
onMount(() => {
api.describeServer().then(info => {
if (info.availableUserDomains?.length) {
pdsHostname = info.availableUserDomains[0]
}
if (info.version) {
pdsVersion = info.version
}
}).catch(() => {})
api.listRepos(1000).then(data => {
userCount = data.repos.length
}).catch(() => {})
const pattern = document.getElementById('dotPattern')
if (!pattern) return
@@ -65,8 +85,20 @@
<div class="pattern-fade"></div>
<nav>
<span class="brand">Tranquil PDS</span>
<span class="nav-meta">0.1.0</span>
<div class="nav-left">
{#if serverConfig.hasLogo}
<img src="/logo" alt="Logo" class="nav-logo" />
{/if}
{#if pdsHostname}
<span class="hostname">{pdsHostname}</span>
{#if userCount !== null}
<span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span>
{/if}
{:else}
<span class="hostname placeholder">loading...</span>
{/if}
</div>
<span class="nav-meta">{pdsVersion || ''}</span>
</nav>
<div class="home">
@@ -139,7 +171,7 @@
<footer class="site-footer">
<span>Open Source</span>
<span>Made with care</span>
<span>Made with patience</span>
</footer>
</div>
@@ -209,7 +241,20 @@
align-items: center;
}
.brand {
.nav-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.nav-logo {
height: 28px;
width: auto;
object-fit: contain;
border-radius: var(--radius-sm);
}
.hostname {
font-weight: var(--font-semibold);
font-size: var(--text-base);
letter-spacing: 0.08em;
@@ -217,6 +262,18 @@
text-transform: uppercase;
}
.hostname.placeholder {
opacity: 0.4;
}
.user-count {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.85);
padding: 4px 10px;
background: rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
}
.nav-meta {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.7);
@@ -319,10 +376,10 @@
.content h2 {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
font-weight: var(--font-bold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
color: var(--accent-light);
margin: var(--space-8) 0 var(--space-5);
}
@@ -381,6 +438,10 @@
.btn {
text-align: center;
}
.nav-meta {
display: none;
}
}
.site-footer {

View File

@@ -132,6 +132,17 @@
</script>
<div class="register-page">
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
{#if error}
<div class="message error">{error}</div>
{/if}
@@ -345,6 +356,50 @@
padding: var(--space-7);
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
h1 {
margin: 0 0 var(--space-3) 0;
}

View File

@@ -303,6 +303,19 @@
</script>
<div class="register-page">
{#if step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
{/if}
<h1>Create Passkey Account</h1>
<p class="subtitle">
{#if step === 'info'}
@@ -541,6 +554,50 @@
padding: var(--space-7);
}
.migrate-callout {
display: flex;
gap: var(--space-4);
padding: var(--space-5);
background: var(--accent-muted);
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
.migrate-icon {
font-size: var(--text-2xl);
line-height: 1;
color: var(--accent);
}
.migrate-content {
flex: 1;
}
.migrate-content strong {
display: block;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.migrate-content p {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.migrate-link {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--accent);
text-decoration: none;
}
.migrate-link:hover {
text-decoration: underline;
}
h1, h2 {
margin: 0 0 var(--space-3) 0;
}

View File

@@ -1,5 +1,17 @@
@import './tokens.css';
@property --accent {
syntax: '<color>';
inherits: true;
initial-value: #2c00ff;
}
@property --secondary {
syntax: '<color>';
inherits: true;
initial-value: #ff2400;
}
*,
*::before,
*::after {
@@ -15,6 +27,7 @@ body {
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease;
}
h1, h2, h3, h4, h5, h6 {
@@ -34,6 +47,7 @@ p {
a {
color: var(--secondary);
text-decoration: none;
transition: color 0.3s ease;
}
a:hover {

View File

@@ -106,14 +106,14 @@
--border-light: #222222;
--border-dark: #333333;
--accent: #2c00ff;
--accent-hover: #4d33ff;
--accent-muted: rgba(44, 0, 255, 0.15);
--accent-light: #4d33ff;
--accent: #7b6bff;
--accent-hover: #9588ff;
--accent-muted: rgba(123, 107, 255, 0.2);
--accent-light: #9588ff;
--secondary: #ff2400;
--secondary-hover: #ff5533;
--secondary-muted: rgba(255, 36, 0, 0.15);
--secondary: #ff6b5b;
--secondary-hover: #ff8577;
--secondary-muted: rgba(255, 107, 91, 0.2);
--success-bg: #1a3d1a;
--success-border: #2d5a2d;

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS server_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO server_config (key, value) VALUES ('server_name', 'Tranquil PDS') ON CONFLICT DO NOTHING;

194
src/api/admin/config.rs Normal file
View File

@@ -0,0 +1,194 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use tracing::error;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerConfigResponse {
pub server_name: String,
pub primary_color: Option<String>,
pub primary_color_dark: Option<String>,
pub secondary_color: Option<String>,
pub secondary_color_dark: Option<String>,
pub logo_cid: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateServerConfigRequest {
pub server_name: Option<String>,
pub primary_color: Option<String>,
pub primary_color_dark: Option<String>,
pub secondary_color: Option<String>,
pub secondary_color_dark: Option<String>,
pub logo_cid: Option<String>,
}
#[derive(Serialize)]
pub struct UpdateServerConfigResponse {
pub success: bool,
}
fn is_valid_hex_color(s: &str) -> bool {
if s.len() != 7 || !s.starts_with('#') {
return false;
}
s[1..].chars().all(|c| c.is_ascii_hexdigit())
}
pub async fn get_server_config(
State(state): State<AppState>,
) -> Result<Json<ServerConfigResponse>, ApiError> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')"
)
.fetch_all(&state.db)
.await?;
let mut server_name = "Tranquil PDS".to_string();
let mut primary_color = None;
let mut primary_color_dark = None;
let mut secondary_color = None;
let mut secondary_color_dark = None;
let mut logo_cid = None;
for (key, value) in rows {
match key.as_str() {
"server_name" => server_name = value,
"primary_color" => primary_color = Some(value),
"primary_color_dark" => primary_color_dark = Some(value),
"secondary_color" => secondary_color = Some(value),
"secondary_color_dark" => secondary_color_dark = Some(value),
"logo_cid" => logo_cid = Some(value),
_ => {}
}
}
Ok(Json(ServerConfigResponse {
server_name,
primary_color,
primary_color_dark,
secondary_color,
secondary_color_dark,
logo_cid,
}))
}
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
)
.bind(key)
.bind(value)
.execute(db)
.await?;
Ok(())
}
async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM server_config WHERE key = $1")
.bind(key)
.execute(db)
.await?;
Ok(())
}
pub async fn update_server_config(
State(state): State<AppState>,
_admin: BearerAuthAdmin,
Json(req): Json<UpdateServerConfigRequest>,
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
if let Some(server_name) = req.server_name {
let trimmed = server_name.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
}
upsert_config(&state.db, "server_name", trimmed).await?;
}
if let Some(ref color) = req.primary_color {
if color.is_empty() {
delete_config(&state.db, "primary_color").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.primary_color_dark {
if color.is_empty() {
delete_config(&state.db, "primary_color_dark").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.secondary_color {
if color.is_empty() {
delete_config(&state.db, "secondary_color").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.secondary_color_dark {
if color.is_empty() {
delete_config(&state.db, "secondary_color_dark").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
}
}
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid: Option<String> = sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await?;
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
(Some(old), true) => Some(old.clone()),
(Some(old), false) if old != logo_cid => Some(old.clone()),
_ => None,
};
if let Some(old_cid) = should_delete_old {
if let Ok(Some(blob)) = sqlx::query!(
"SELECT storage_key FROM blobs WHERE cid = $1",
old_cid
)
.fetch_optional(&state.db)
.await
{
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
.execute(&state.db)
.await
{
error!("Failed to delete old logo blob record: {:?}", e);
}
}
}
if logo_cid.is_empty() {
delete_config(&state.db, "logo_cid").await?;
} else {
upsert_config(&state.db, "logo_cid", logo_cid).await?;
}
}
Ok(Json(UpdateServerConfigResponse { success: true }))
}

View File

@@ -1,4 +1,5 @@
pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod status;
@@ -7,6 +8,7 @@ pub use account::{
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
update_account_email, update_account_handle, update_account_password,
};
pub use config::{get_server_config, update_server_config};
pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};

57
src/api/server/logo.rs Normal file
View File

@@ -0,0 +1,57 @@
use crate::state::AppState;
use axum::{
body::Body,
extract::State,
http::StatusCode,
http::header,
response::{IntoResponse, Response},
};
use tracing::error;
pub async fn get_logo(State(state): State<AppState>) -> Response {
let logo_cid: Option<String> = match sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await
{
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let cid = match logo_cid {
Some(c) if !c.is_empty() => c,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let blob = match sqlx::query!(
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
cid
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
error!("DB error fetching blob: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
match state.blob_store.get(&blob.storage_key).await {
Ok(data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &blob.mime_type)
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(data))
.unwrap(),
Err(e) => {
error!("Failed to fetch logo from storage: {:?}", e);
StatusCode::NOT_FOUND.into_response()
}
}
}

View File

@@ -20,7 +20,8 @@ pub async fn describe_server() -> impl IntoResponse {
Json(json!({
"availableUserDomains": domains,
"inviteCodeRequired": invite_code_required,
"did": format!("did:web:{}", pds_hostname)
"did": format!("did:web:{}", pds_hostname),
"version": env!("CARGO_PKG_VERSION")
}))
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {

View File

@@ -2,6 +2,7 @@ pub mod account_status;
pub mod app_password;
pub mod email;
pub mod invite;
pub mod logo;
pub mod meta;
pub mod passkey_account;
pub mod passkeys;
@@ -20,6 +21,7 @@ pub use account_status::{
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{confirm_email, request_email_update, update_email};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,

View File

@@ -35,6 +35,7 @@ pub fn app(state: AppState) -> Router {
.route("/health", get(api::server::health))
.route("/xrpc/_health", get(api::server::health))
.route("/robots.txt", get(api::server::robots_txt))
.route("/logo", get(api::server::get_logo))
.route(
"/xrpc/com.atproto.server.describeServer",
get(api::server::describe_server),
@@ -402,6 +403,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.admin.getServerStats",
get(api::admin::get_server_stats),
)
.route(
"/xrpc/com.tranquil.server.getConfig",
get(api::admin::get_server_config),
)
.route(
"/xrpc/com.tranquil.admin.updateServerConfig",
post(api::admin::update_server_config),
)
.route(
"/xrpc/com.atproto.admin.disableAccountInvites",
post(api::admin::disable_account_invites),

View File

@@ -172,7 +172,7 @@ pub async fn frontend_client_metadata(
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: "atproto transition:generic".to_string(),
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
dpop_bound_access_tokens: true,