diff --git a/.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json b/.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json similarity index 59% rename from .sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json rename to .sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json index 4b21415..04c6889 100644 --- a/.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json +++ b/.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()\n WHERE id = $1\n ", + "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { @@ -8,10 +8,11 @@ "Int4", "Text", "Text", - "Timestamptz" + "Timestamptz", + "Text" ] }, "nullable": [] }, - "hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284" + "hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f" } diff --git a/.sqlx/query-3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9.json b/.sqlx/query-3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9.json new file mode 100644 index 0000000..6011891 --- /dev/null +++ b/.sqlx/query-3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "scopes", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9" +} diff --git a/.sqlx/query-44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459.json b/.sqlx/query-44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459.json new file mode 100644 index 0000000..2f04a6a --- /dev/null +++ b/.sqlx/query-44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "access_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459" +} diff --git a/.sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json b/.sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json similarity index 54% rename from .sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json rename to .sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json index 8c5b220..f9b3500 100644 --- a/.sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json +++ b/.sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", + "query": "SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", "describe": { "columns": [ { @@ -15,11 +15,16 @@ }, { "ordinal": 2, + "name": "scope", + "type_info": "Text" + }, + { + "ordinal": 3, "name": "key_bytes", "type_info": "Bytea" }, { - "ordinal": 3, + "ordinal": 4, "name": "encryption_version", "type_info": "Int4" } @@ -32,9 +37,10 @@ "nullable": [ false, false, + true, false, true ] }, - "hash": "e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8" + "hash": "6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883" } diff --git a/.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json b/.sqlx/query-815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24.json similarity index 69% rename from .sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json rename to .sqlx/query-815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24.json index 3ef7a34..7d12be6 100644 --- a/.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json +++ b/.sqlx/query-815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", "describe": { "columns": [], "parameters": { @@ -11,10 +11,11 @@ "Timestamptz", "Timestamptz", "Bool", - "Bool" + "Bool", + "Text" ] }, "nullable": [] }, - "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" + "hash": "815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24" } diff --git a/.sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json b/.sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json similarity index 60% rename from .sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json rename to .sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json index df3aa67..d8be7b1 100644 --- a/.sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json +++ b/.sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", + "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", "describe": { "columns": [], "parameters": { @@ -9,10 +9,11 @@ "Text", "Text", "Timestamptz", - "Bool" + "Bool", + "Text" ] }, "nullable": [] }, - "hash": "15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1" + "hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40" } diff --git a/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json b/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json deleted file mode 100644 index 004e5c0..0000000 --- a/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "one", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7" -} diff --git a/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json b/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json deleted file mode 100644 index 81649b0..0000000 --- a/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "password_hash", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37" -} diff --git a/.sqlx/query-cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01.json b/.sqlx/query-f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb.json similarity index 60% rename from .sqlx/query-cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01.json rename to .sqlx/query-f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb.json index 90c7326..93f1ba5 100644 --- a/.sqlx/query-cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01.json +++ b/.sqlx/query-f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", + "query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "privileged", "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "Text" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, false, - false + false, + true ] }, - "hash": "cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01" + "hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb" } diff --git a/.sqlx/query-fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913.json b/.sqlx/query-fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913.json new file mode 100644 index 0000000..c1da7a4 --- /dev/null +++ b/.sqlx/query-fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "details", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "current_refresh_token", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "scope", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913" +} diff --git a/README.md b/README.md index 413aa0b..a48f832 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Another excellent PDS is [Cocoon](https://github.com/haileyok/cocoon), written i This software isn't an afterthought by a company with limited resources. -It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, and a built-in web UI for account management, OAuth consent, repo browsing, and admin. +It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. @@ -66,4 +66,5 @@ This project is very grateful to [@nel.pet](https://bsky.app/profile/did:plc:h5w ## License -TBD +AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details. + diff --git a/TODO.md b/TODO.md index 79d24ee..d51837c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,13 +2,6 @@ ## Active development -### Frontend -So like... make the thing unique, make it cool. - -- [ ] Frontpage that explains what this thing is -- [ ] Unique "brand" style both unauthed and authed -- [ ] Better documentation on how to sub out the entire frontend for whatever the users want - ### Delegated accounts Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. @@ -90,3 +83,5 @@ Web UI: OAuth login, registration, email verification, password reset, multi-acc Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods. + +App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d24cd42..3763cf5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -93,6 +93,7 @@ export interface Session { export interface AppPassword { name: string; createdAt: string; + scopes?: string; } export interface InviteCode { @@ -226,11 +227,14 @@ export const api = { async createAppPassword( token: string, name: string, - ): Promise<{ name: string; password: string; createdAt: string }> { + scopes?: string, + ): Promise< + { name: string; password: string; createdAt: string; scopes?: string } + > { return xrpc("com.atproto.server.createAppPassword", { method: "POST", token, - body: { name }, + body: { name, scopes }, }); }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8bfd8e1..c057f30 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -266,7 +266,12 @@ "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" + "acknowledgeLabel": "I have saved my app password in a secure location", + "permissions": "Permissions", + "scopeFull": "Full Access", + "scopeReadOnly": "Read Only", + "scopePostOnly": "Post Only", + "scopeCustom": "Custom" }, "sessions": { "title": "Active Sessions", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index d744284..aae5281 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -266,7 +266,12 @@ "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" + "acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan", + "permissions": "Käyttöoikeudet", + "scopeFull": "Täydet oikeudet", + "scopeReadOnly": "Vain luku", + "scopePostOnly": "Vain julkaisut", + "scopeCustom": "Mukautettu" }, "sessions": { "title": "Aktiiviset istunnot", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 57820a0..cffe171 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -266,7 +266,12 @@ "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", "saveWarningTitle": "重要: このアプリパスワードを保存してください!", "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", - "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました" + "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました", + "permissions": "権限", + "scopeFull": "フルアクセス", + "scopeReadOnly": "読み取り専用", + "scopePostOnly": "投稿のみ", + "scopeCustom": "カスタム" }, "sessions": { "title": "アクティブセッション", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 953a76a..a505f3a 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -266,7 +266,12 @@ "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", - "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다" + "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다", + "permissions": "권한", + "scopeFull": "전체 권한", + "scopeReadOnly": "읽기 전용", + "scopePostOnly": "게시만 가능", + "scopeCustom": "사용자 지정" }, "sessions": { "title": "활성 세션", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 1171603..8a69fac 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -266,7 +266,12 @@ "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" + "acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats", + "permissions": "Behörigheter", + "scopeFull": "Full åtkomst", + "scopeReadOnly": "Endast läsning", + "scopePostOnly": "Endast publicering", + "scopeCustom": "Anpassad" }, "sessions": { "title": "Aktiva sessioner", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 431656c..f84e1f4 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -266,7 +266,12 @@ "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", "saveWarningTitle": "重要:请保存此应用专用密码!", "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", - "acknowledgeLabel": "我已将应用专用密码保存在安全的地方" + "acknowledgeLabel": "我已将应用专用密码保存在安全的地方", + "permissions": "权限", + "scopeFull": "完全访问", + "scopeReadOnly": "只读", + "scopePostOnly": "仅发帖", + "scopeCustom": "自定义" }, "sessions": { "title": "登录会话", diff --git a/frontend/src/routes/AppPasswords.svelte b/frontend/src/routes/AppPasswords.svelte index 9f13e4e..798755f 100644 --- a/frontend/src/routes/AppPasswords.svelte +++ b/frontend/src/routes/AppPasswords.svelte @@ -9,11 +9,25 @@ let loading = $state(true) let error = $state(null) let newPasswordName = $state('') + let selectedScope = $state(null) let creating = $state(false) let createdPassword = $state<{ name: string; password: string } | null>(null) let passwordCopied = $state(false) let passwordAcknowledged = $state(false) let revoking = $state(null) + + const SCOPE_PRESETS = [ + { id: 'full', label: 'appPasswords.scopeFull', scopes: null }, + { id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: 'rpc:app.bsky.*?aud=* rpc:chat.bsky.*?aud=* account:status?action=read' }, + { id: 'post', label: 'appPasswords.scopePostOnly', scopes: 'repo:app.bsky.feed.post?action=create blob:*/*' }, + ] + + function getScopeLabel(scopes: string | null | undefined): string { + if (!scopes) return $_('appPasswords.scopeFull') + const preset = SCOPE_PRESETS.find(p => p.scopes === scopes) + if (preset) return $_(preset.label) + return $_('appPasswords.scopeCustom') + } $effect(() => { if (!auth.loading && !auth.session) { navigate('/login') @@ -43,9 +57,11 @@ creating = true error = null try { - const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim()) + const scopeValue = selectedScope === null ? undefined : selectedScope + const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined) createdPassword = { name: result.name, password: result.password } newPasswordName = '' + selectedScope = null await loadPasswords() } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to create app password' @@ -122,6 +138,22 @@ disabled={creating} required /> +
+ {$_('appPasswords.permissions')}: +
+ {#each SCOPE_PRESETS as preset} + + {/each} +
+
@@ -139,7 +171,10 @@
  • {pw.name} - {$_('common.created')} {formatDate(pw.createdAt)} + + {getScopeLabel(pw.scopes)} + {$_('common.created')} {formatDate(pw.createdAt)} +