mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 04:04:14 +00:00
Frontend on /app path for easy custom homepage
This commit is contained in:
+16
-11
@@ -2,7 +2,7 @@
|
||||
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 { initI18n } from './lib/i18n'
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
@@ -34,13 +34,6 @@
|
||||
import ActAs from './routes/ActAs.svelte'
|
||||
import Migration from './routes/Migration.svelte'
|
||||
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
|
||||
import Home from './routes/Home.svelte'
|
||||
|
||||
if (window.location.pathname === '/migrate') {
|
||||
const newUrl = `${window.location.origin}/${window.location.search}#/migrate`
|
||||
window.location.replace(newUrl)
|
||||
}
|
||||
|
||||
initI18n()
|
||||
|
||||
const auth = getAuthState()
|
||||
@@ -48,7 +41,7 @@
|
||||
let oauthCallbackPending = $state(hasOAuthCallback())
|
||||
|
||||
function hasOAuthCallback(): boolean {
|
||||
if (window.location.hash === '#/migrate') {
|
||||
if (window.location.pathname === '/app/migrate') {
|
||||
return false
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -59,12 +52,24 @@
|
||||
initServerConfig()
|
||||
initAuth().then(({ oauthLoginCompleted }) => {
|
||||
if (oauthLoginCompleted) {
|
||||
navigate('/dashboard')
|
||||
navigate('/dashboard', true)
|
||||
}
|
||||
oauthCallbackPending = false
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.loading) return
|
||||
const path = getCurrentPath()
|
||||
if (path === '/') {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard', true)
|
||||
} else {
|
||||
navigate('/login', true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getComponent(path: string) {
|
||||
switch (path) {
|
||||
case '/login':
|
||||
@@ -128,7 +133,7 @@
|
||||
case '/did-document':
|
||||
return DidDocumentEditor
|
||||
default:
|
||||
return Home
|
||||
return Login
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-65
@@ -237,7 +237,7 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async confirmSignup(
|
||||
confirmSignup(
|
||||
did: string,
|
||||
verificationCode: string,
|
||||
): Promise<ConfirmSignupResult> {
|
||||
@@ -247,32 +247,32 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
return xrpc("com.atproto.server.resendVerification", {
|
||||
method: "POST",
|
||||
body: { did },
|
||||
});
|
||||
},
|
||||
|
||||
async createSession(identifier: string, password: string): Promise<Session> {
|
||||
createSession(identifier: string, password: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.createSession", {
|
||||
method: "POST",
|
||||
body: { identifier, password },
|
||||
});
|
||||
},
|
||||
|
||||
async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
|
||||
checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
|
||||
return xrpc("_checkEmailVerified", {
|
||||
method: "POST",
|
||||
body: { identifier },
|
||||
});
|
||||
},
|
||||
|
||||
async getSession(token: string): Promise<Session> {
|
||||
getSession(token: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.getSession", { token });
|
||||
},
|
||||
|
||||
async refreshSession(refreshJwt: string): Promise<Session> {
|
||||
refreshSession(refreshJwt: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.refreshSession", {
|
||||
method: "POST",
|
||||
token: refreshJwt,
|
||||
@@ -286,11 +286,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
return xrpc("com.atproto.server.listAppPasswords", { token });
|
||||
},
|
||||
|
||||
async createAppPassword(
|
||||
createAppPassword(
|
||||
token: string,
|
||||
name: string,
|
||||
scopes?: string,
|
||||
@@ -312,11 +312,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
return xrpc("com.atproto.server.getAccountInviteCodes", { token });
|
||||
},
|
||||
|
||||
async createInviteCode(
|
||||
createInviteCode(
|
||||
token: string,
|
||||
useCount: number = 1,
|
||||
): Promise<{ code: string }> {
|
||||
@@ -341,7 +341,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async requestEmailUpdate(
|
||||
requestEmailUpdate(
|
||||
token: string,
|
||||
): Promise<{ tokenRequired: boolean }> {
|
||||
return xrpc("com.atproto.server.requestEmailUpdate", {
|
||||
@@ -388,7 +388,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async describeServer(): Promise<{
|
||||
describeServer(): Promise<{
|
||||
availableUserDomains: string[];
|
||||
inviteCodeRequired: boolean;
|
||||
links?: { privacyPolicy?: string; termsOfService?: string };
|
||||
@@ -399,7 +399,7 @@ export const api = {
|
||||
return xrpc("com.atproto.server.describeServer");
|
||||
},
|
||||
|
||||
async listRepos(limit?: number): Promise<{
|
||||
listRepos(limit?: number): Promise<{
|
||||
repos: Array<{ did: string; head: string; rev: string }>;
|
||||
cursor?: string;
|
||||
}> {
|
||||
@@ -408,7 +408,7 @@ export const api = {
|
||||
return xrpc("com.atproto.sync.listRepos", { params });
|
||||
},
|
||||
|
||||
async getNotificationPrefs(token: string): Promise<{
|
||||
getNotificationPrefs(token: string): Promise<{
|
||||
preferredChannel: string;
|
||||
email: string;
|
||||
discordId: string | null;
|
||||
@@ -421,7 +421,7 @@ export const api = {
|
||||
return xrpc("_account.getNotificationPrefs", { token });
|
||||
},
|
||||
|
||||
async updateNotificationPrefs(token: string, prefs: {
|
||||
updateNotificationPrefs(token: string, prefs: {
|
||||
preferredChannel?: string;
|
||||
discordId?: string;
|
||||
telegramUsername?: string;
|
||||
@@ -434,7 +434,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async confirmChannelVerification(
|
||||
confirmChannelVerification(
|
||||
token: string,
|
||||
channel: string,
|
||||
identifier: string,
|
||||
@@ -447,7 +447,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getNotificationHistory(token: string): Promise<{
|
||||
getNotificationHistory(token: string): Promise<{
|
||||
notifications: Array<{
|
||||
createdAt: string;
|
||||
channel: string;
|
||||
@@ -460,7 +460,7 @@ export const api = {
|
||||
return xrpc("_account.getNotificationHistory", { token });
|
||||
},
|
||||
|
||||
async getServerStats(token: string): Promise<{
|
||||
getServerStats(token: string): Promise<{
|
||||
userCount: number;
|
||||
repoCount: number;
|
||||
recordCount: number;
|
||||
@@ -469,7 +469,7 @@ export const api = {
|
||||
return xrpc("_admin.getServerStats", { token });
|
||||
},
|
||||
|
||||
async getServerConfig(): Promise<{
|
||||
getServerConfig(): Promise<{
|
||||
serverName: string;
|
||||
primaryColor: string | null;
|
||||
primaryColorDark: string | null;
|
||||
@@ -480,7 +480,7 @@ export const api = {
|
||||
return xrpc("_server.getConfig");
|
||||
},
|
||||
|
||||
async updateServerConfig(
|
||||
updateServerConfig(
|
||||
token: string,
|
||||
config: {
|
||||
serverName?: string;
|
||||
@@ -541,24 +541,24 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async removePassword(token: string): Promise<{ success: boolean }> {
|
||||
removePassword(token: string): Promise<{ success: boolean }> {
|
||||
return xrpc("_account.removePassword", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
return xrpc("_account.getPasswordStatus", { token });
|
||||
},
|
||||
|
||||
async getLegacyLoginPreference(
|
||||
getLegacyLoginPreference(
|
||||
token: string,
|
||||
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
|
||||
return xrpc("_account.getLegacyLoginPreference", { token });
|
||||
},
|
||||
|
||||
async updateLegacyLoginPreference(
|
||||
updateLegacyLoginPreference(
|
||||
token: string,
|
||||
allowLegacyLogin: boolean,
|
||||
): Promise<{ allowLegacyLogin: boolean }> {
|
||||
@@ -569,7 +569,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateLocale(
|
||||
updateLocale(
|
||||
token: string,
|
||||
preferredLocale: string,
|
||||
): Promise<{ preferredLocale: string }> {
|
||||
@@ -580,7 +580,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listSessions(token: string): Promise<{
|
||||
listSessions(token: string): Promise<{
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
sessionType: string;
|
||||
@@ -601,14 +601,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
return xrpc("_account.revokeAllSessions", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async searchAccounts(token: string, options?: {
|
||||
searchAccounts(token: string, options?: {
|
||||
handle?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
@@ -630,7 +630,7 @@ export const api = {
|
||||
return xrpc("com.atproto.admin.searchAccounts", { token, params });
|
||||
},
|
||||
|
||||
async getInviteCodes(token: string, options?: {
|
||||
getInviteCodes(token: string, options?: {
|
||||
sort?: "recent" | "usage";
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
@@ -665,7 +665,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getAccountInfo(token: string, did: string): Promise<{
|
||||
getAccountInfo(token: string, did: string): Promise<{
|
||||
did: string;
|
||||
handle: string;
|
||||
email?: string;
|
||||
@@ -701,7 +701,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async describeRepo(token: string, repo: string): Promise<{
|
||||
describeRepo(token: string, repo: string): Promise<{
|
||||
handle: string;
|
||||
did: string;
|
||||
didDoc: unknown;
|
||||
@@ -714,7 +714,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listRecords(token: string, repo: string, collection: string, options?: {
|
||||
listRecords(token: string, repo: string, collection: string, options?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
reverse?: boolean;
|
||||
@@ -729,7 +729,7 @@ export const api = {
|
||||
return xrpc("com.atproto.repo.listRecords", { token, params });
|
||||
},
|
||||
|
||||
async getRecord(
|
||||
getRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -745,7 +745,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async createRecord(
|
||||
createRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -762,7 +762,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async putRecord(
|
||||
putRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -792,13 +792,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getTotpStatus(
|
||||
getTotpStatus(
|
||||
token: string,
|
||||
): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
|
||||
return xrpc("com.atproto.server.getTotpStatus", { token });
|
||||
},
|
||||
|
||||
async createTotpSecret(
|
||||
createTotpSecret(
|
||||
token: string,
|
||||
): Promise<{ uri: string; qrBase64: string }> {
|
||||
return xrpc("com.atproto.server.createTotpSecret", {
|
||||
@@ -807,7 +807,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async enableTotp(
|
||||
enableTotp(
|
||||
token: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; backupCodes: string[] }> {
|
||||
@@ -818,7 +818,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async disableTotp(
|
||||
disableTotp(
|
||||
token: string,
|
||||
password: string,
|
||||
code: string,
|
||||
@@ -830,7 +830,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async regenerateBackupCodes(
|
||||
regenerateBackupCodes(
|
||||
token: string,
|
||||
password: string,
|
||||
code: string,
|
||||
@@ -842,7 +842,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async startPasskeyRegistration(
|
||||
startPasskeyRegistration(
|
||||
token: string,
|
||||
friendlyName?: string,
|
||||
): Promise<{ options: unknown }> {
|
||||
@@ -853,7 +853,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async finishPasskeyRegistration(
|
||||
finishPasskeyRegistration(
|
||||
token: string,
|
||||
credential: unknown,
|
||||
friendlyName?: string,
|
||||
@@ -865,7 +865,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listPasskeys(token: string): Promise<{
|
||||
listPasskeys(token: string): Promise<{
|
||||
passkeys: Array<{
|
||||
id: string;
|
||||
credentialId: string;
|
||||
@@ -897,7 +897,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listTrustedDevices(token: string): Promise<{
|
||||
listTrustedDevices(token: string): Promise<{
|
||||
devices: Array<{
|
||||
id: string;
|
||||
userAgent: string | null;
|
||||
@@ -910,7 +910,7 @@ export const api = {
|
||||
return xrpc("_account.listTrustedDevices", { token });
|
||||
},
|
||||
|
||||
async revokeTrustedDevice(
|
||||
revokeTrustedDevice(
|
||||
token: string,
|
||||
deviceId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
@@ -921,7 +921,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateTrustedDevice(
|
||||
updateTrustedDevice(
|
||||
token: string,
|
||||
deviceId: string,
|
||||
friendlyName: string,
|
||||
@@ -933,7 +933,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getReauthStatus(token: string): Promise<{
|
||||
getReauthStatus(token: string): Promise<{
|
||||
requiresReauth: boolean;
|
||||
lastReauthAt: string | null;
|
||||
availableMethods: string[];
|
||||
@@ -941,7 +941,7 @@ export const api = {
|
||||
return xrpc("_account.getReauthStatus", { token });
|
||||
},
|
||||
|
||||
async reauthPassword(
|
||||
reauthPassword(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -952,7 +952,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reauthTotp(
|
||||
reauthTotp(
|
||||
token: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -963,14 +963,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
return xrpc("_account.reauthPasskeyStart", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async reauthPasskeyFinish(
|
||||
reauthPasskeyFinish(
|
||||
token: string,
|
||||
credential: unknown,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -981,14 +981,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
|
||||
reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
|
||||
return xrpc("com.atproto.server.reserveSigningKey", {
|
||||
method: "POST",
|
||||
body: { did },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecommendedDidCredentials(token: string): Promise<{
|
||||
getRecommendedDidCredentials(token: string): Promise<{
|
||||
rotationKeys?: string[];
|
||||
alsoKnownAs?: string[];
|
||||
verificationMethods?: { atproto?: string };
|
||||
@@ -1043,7 +1043,7 @@ export const api = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async startPasskeyRegistrationForSetup(
|
||||
startPasskeyRegistrationForSetup(
|
||||
did: string,
|
||||
setupToken: string,
|
||||
friendlyName?: string,
|
||||
@@ -1054,7 +1054,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async completePasskeySetup(
|
||||
completePasskeySetup(
|
||||
did: string,
|
||||
setupToken: string,
|
||||
passkeyCredential: unknown,
|
||||
@@ -1071,14 +1071,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
return xrpc("_account.requestPasskeyRecovery", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
},
|
||||
|
||||
async recoverPasskeyAccount(
|
||||
recoverPasskeyAccount(
|
||||
did: string,
|
||||
recoveryToken: string,
|
||||
newPassword: string,
|
||||
@@ -1089,7 +1089,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async verifyMigrationEmail(
|
||||
verifyMigrationEmail(
|
||||
token: string,
|
||||
email: string,
|
||||
): Promise<{ success: boolean; did: string }> {
|
||||
@@ -1099,14 +1099,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
|
||||
resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
|
||||
return xrpc("com.atproto.server.resendMigrationVerification", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
},
|
||||
|
||||
async verifyToken(
|
||||
verifyToken(
|
||||
token: string,
|
||||
identifier: string,
|
||||
accessToken?: string,
|
||||
@@ -1123,11 +1123,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getDidDocument(token: string): Promise<DidDocument> {
|
||||
getDidDocument(token: string): Promise<DidDocument> {
|
||||
return xrpc("_account.getDidDocument", { token });
|
||||
},
|
||||
|
||||
async updateDidDocument(
|
||||
updateDidDocument(
|
||||
token: string,
|
||||
params: {
|
||||
verificationMethods?: VerificationMethod[];
|
||||
@@ -1170,7 +1170,7 @@ export const api = {
|
||||
return res.arrayBuffer();
|
||||
},
|
||||
|
||||
async listBackups(token: string): Promise<{
|
||||
listBackups(token: string): Promise<{
|
||||
backups: Array<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
@@ -1199,7 +1199,7 @@ export const api = {
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
async createBackup(token: string): Promise<{
|
||||
createBackup(token: string): Promise<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
sizeBytes: number;
|
||||
@@ -1219,7 +1219,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async setBackupEnabled(
|
||||
setBackupEnabled(
|
||||
token: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
|
||||
@@ -40,7 +40,7 @@ interface AuthState {
|
||||
savedAccounts: SavedAccount[];
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
const state = $state<AuthState>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -318,11 +318,18 @@ export function setSession(
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
const did = state.session.did;
|
||||
const refreshToken = state.session.refreshJwt;
|
||||
try {
|
||||
await api.deleteSession(state.session.accessJwt);
|
||||
await fetch("/oauth/revoke", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token: refreshToken }),
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
}
|
||||
removeSavedAccount(did);
|
||||
}
|
||||
state.session = null;
|
||||
saveSession(null);
|
||||
|
||||
@@ -188,11 +188,11 @@ export class AtprotoClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async describeServer(): Promise<ServerDescription> {
|
||||
describeServer(): Promise<ServerDescription> {
|
||||
return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
|
||||
}
|
||||
|
||||
async getServiceAuth(
|
||||
getServiceAuth(
|
||||
aud: string,
|
||||
lxm?: string,
|
||||
): Promise<{ token: string }> {
|
||||
@@ -203,7 +203,7 @@ export class AtprotoClient {
|
||||
return this.xrpc("com.atproto.server.getServiceAuth", { params });
|
||||
}
|
||||
|
||||
async getRepo(did: string): Promise<Uint8Array> {
|
||||
getRepo(did: string): Promise<Uint8Array> {
|
||||
return this.xrpc("com.atproto.sync.getRepo", {
|
||||
params: { did },
|
||||
});
|
||||
@@ -662,6 +662,61 @@ export function buildOAuthAuthorizationUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function initiateOAuthWithPAR(
|
||||
metadata: OAuthServerMetadata,
|
||||
params: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
dpopJkt?: string;
|
||||
loginHint?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!metadata.pushed_authorization_request_endpoint) {
|
||||
return buildOAuthAuthorizationUrl(metadata, params);
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state: params.state,
|
||||
scope: params.scope ?? "atproto",
|
||||
});
|
||||
|
||||
if (params.dpopJkt) {
|
||||
body.set("dpop_jkt", params.dpopJkt);
|
||||
}
|
||||
if (params.loginHint) {
|
||||
body.set("login_hint", params.loginHint);
|
||||
}
|
||||
|
||||
const res = await fetch(metadata.pushed_authorization_request_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({
|
||||
error: "par_error",
|
||||
error_description: res.statusText,
|
||||
}));
|
||||
throw new Error(err.error_description || err.error || "PAR request failed");
|
||||
}
|
||||
|
||||
const { request_uri } = await res.json();
|
||||
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("client_id", params.clientId);
|
||||
authUrl.searchParams.set("request_uri", request_uri);
|
||||
return authUrl.toString();
|
||||
}
|
||||
|
||||
export async function exchangeOAuthCode(
|
||||
metadata: OAuthServerMetadata,
|
||||
params: {
|
||||
@@ -839,7 +894,7 @@ export function getMigrationOAuthClientId(): string {
|
||||
}
|
||||
|
||||
export function getMigrationOAuthRedirectUri(): string {
|
||||
return `${globalThis.location.origin}/migrate`;
|
||||
return `${globalThis.location.origin}/app/migrate`;
|
||||
}
|
||||
|
||||
export interface DPoPKeyPair {
|
||||
|
||||
@@ -107,8 +107,7 @@ export async function migrateBlobs(
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const isNetworkError =
|
||||
errorMessage.includes("fetch") ||
|
||||
const isNetworkError = errorMessage.includes("fetch") ||
|
||||
errorMessage.includes("network") ||
|
||||
errorMessage.includes("CORS") ||
|
||||
errorMessage.includes("Failed to fetch") ||
|
||||
@@ -124,7 +123,9 @@ export async function migrateBlobs(
|
||||
if (migrated > 0) {
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`,
|
||||
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${
|
||||
remaining + 1
|
||||
} could not be fetched - these may need to be re-uploaded.`,
|
||||
});
|
||||
} else {
|
||||
onProgress({
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from "./types";
|
||||
import {
|
||||
AtprotoClient,
|
||||
buildOAuthAuthorizationUrl,
|
||||
clearDPoPKey,
|
||||
createLocalClient,
|
||||
exchangeOAuthCode,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
getMigrationOAuthClientId,
|
||||
getMigrationOAuthRedirectUri,
|
||||
getOAuthServerMetadata,
|
||||
initiateOAuthWithPAR,
|
||||
loadDPoPKey,
|
||||
resolvePdsUrl,
|
||||
saveDPoPKey,
|
||||
@@ -84,8 +84,6 @@ export function createInboundMigrationFlow() {
|
||||
let sourceClient: AtprotoClient | null = null;
|
||||
let localClient: AtprotoClient | null = null;
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
|
||||
null;
|
||||
|
||||
function setStep(step: InboundStep) {
|
||||
state.step = step;
|
||||
@@ -141,7 +139,6 @@ export function createInboundMigrationFlow() {
|
||||
"Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.",
|
||||
);
|
||||
}
|
||||
sourceOAuthMetadata = metadata;
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
const oauthState = generateOAuthState();
|
||||
@@ -155,8 +152,11 @@ export function createInboundMigrationFlow() {
|
||||
localStorage.setItem("migration_source_did", state.sourceDid);
|
||||
localStorage.setItem("migration_source_handle", state.sourceHandle);
|
||||
localStorage.setItem("migration_oauth_issuer", metadata.issuer);
|
||||
if (state.resumeToStep) {
|
||||
localStorage.setItem("migration_resume_to_step", state.resumeToStep);
|
||||
}
|
||||
|
||||
const authUrl = buildOAuthAuthorizationUrl(metadata, {
|
||||
const authUrl = await initiateOAuthWithPAR(metadata, {
|
||||
clientId: getMigrationOAuthClientId(),
|
||||
redirectUri: getMigrationOAuthRedirectUri(),
|
||||
codeChallenge,
|
||||
@@ -185,6 +185,7 @@ export function createInboundMigrationFlow() {
|
||||
localStorage.removeItem("migration_source_did");
|
||||
localStorage.removeItem("migration_source_handle");
|
||||
localStorage.removeItem("migration_oauth_issuer");
|
||||
localStorage.removeItem("migration_resume_to_step");
|
||||
}
|
||||
|
||||
async function handleOAuthCallback(
|
||||
@@ -199,6 +200,12 @@ export function createInboundMigrationFlow() {
|
||||
const sourceDid = localStorage.getItem("migration_source_did");
|
||||
const sourceHandle = localStorage.getItem("migration_source_handle");
|
||||
const oauthIssuer = localStorage.getItem("migration_oauth_issuer");
|
||||
const savedResumeToStep = localStorage.getItem("migration_resume_to_step");
|
||||
|
||||
if (savedResumeToStep) {
|
||||
state.needsReauth = true;
|
||||
state.resumeToStep = savedResumeToStep as InboundMigrationState["step"];
|
||||
}
|
||||
|
||||
if (returnedState !== savedState) {
|
||||
cleanupOAuthSessionData();
|
||||
@@ -229,7 +236,6 @@ export function createInboundMigrationFlow() {
|
||||
cleanupOAuthSessionData();
|
||||
throw new Error("Could not fetch OAuth server metadata");
|
||||
}
|
||||
sourceOAuthMetadata = metadata;
|
||||
|
||||
migrationLog("handleOAuthCallback: Exchanging code for tokens");
|
||||
|
||||
@@ -269,8 +275,8 @@ export function createInboundMigrationFlow() {
|
||||
];
|
||||
|
||||
if (postEmailSteps.includes(targetStep)) {
|
||||
localClient = createLocalClient();
|
||||
if (state.authMethod === "passkey" && state.passkeySetupToken) {
|
||||
localClient = createLocalClient();
|
||||
setStep("passkey-setup");
|
||||
migrationLog(
|
||||
"handleOAuthCallback: Resuming passkey flow at passkey-setup",
|
||||
@@ -281,6 +287,10 @@ export function createInboundMigrationFlow() {
|
||||
"handleOAuthCallback: Resuming at email-verify for re-auth",
|
||||
);
|
||||
}
|
||||
} else if (targetStep === "email-verify") {
|
||||
localClient = createLocalClient();
|
||||
setStep("email-verify");
|
||||
migrationLog("handleOAuthCallback: Resuming at email-verify");
|
||||
} else {
|
||||
setStep(targetStep);
|
||||
}
|
||||
@@ -550,21 +560,34 @@ export function createInboundMigrationFlow() {
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
if (checkingEmailVerification) return false;
|
||||
if (!sourceClient || !localClient) return false;
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
return false;
|
||||
}
|
||||
if (!localClient) return false;
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const verified = await localClient.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
migrationLog(
|
||||
"checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup",
|
||||
);
|
||||
setStep("passkey-setup");
|
||||
return true;
|
||||
}
|
||||
|
||||
await localClient.loginDeactivated(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
);
|
||||
|
||||
if (!sourceClient) {
|
||||
setStep("source-handle");
|
||||
setError(
|
||||
"Email verified! Please log in to your old account again to complete the migration.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.sourceDid.startsWith("did:web:")) {
|
||||
const credentials = await localClient.getRecommendedDidCredentials();
|
||||
state.targetVerificationMethod =
|
||||
@@ -856,7 +879,6 @@ export function createInboundMigrationFlow() {
|
||||
};
|
||||
sourceClient = null;
|
||||
passkeySetup = null;
|
||||
sourceOAuthMetadata = null;
|
||||
clearMigrationState();
|
||||
clearDPoPKey();
|
||||
}
|
||||
|
||||
@@ -254,11 +254,13 @@ export interface OAuthServerMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
pushed_authorization_request_endpoint?: string;
|
||||
scopes_supported?: string[];
|
||||
response_types_supported?: string[];
|
||||
grant_types_supported?: string[];
|
||||
code_challenge_methods_supported?: string[];
|
||||
dpop_signing_alg_values_supported?: string[];
|
||||
require_pushed_authorization_requests?: boolean;
|
||||
}
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
|
||||
@@ -10,7 +10,7 @@ const SCOPES = [
|
||||
const CLIENT_ID = !(import.meta.env.DEV)
|
||||
? `${globalThis.location.origin}/oauth/client-metadata.json`
|
||||
: `http://localhost/?scope=${SCOPES}`;
|
||||
const REDIRECT_URI = `${globalThis.location.origin}/`;
|
||||
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
|
||||
|
||||
interface OAuthState {
|
||||
state: string;
|
||||
@@ -26,7 +26,7 @@ function generateRandomString(length: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
@@ -191,7 +191,7 @@ export async function refreshOAuthToken(
|
||||
export function checkForOAuthCallback():
|
||||
| { code: string; state: string }
|
||||
| null {
|
||||
if (globalThis.location.hash === "#/migrate") {
|
||||
if (globalThis.location.pathname === "/app/migrate") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createRegistrationFlow(
|
||||
mode: RegistrationMode,
|
||||
pdsHostname: string,
|
||||
) {
|
||||
let state = $state<RegistrationFlowState>({
|
||||
const state = $state<RegistrationFlowState>({
|
||||
mode,
|
||||
step: "info",
|
||||
info: {
|
||||
@@ -80,7 +80,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedFromInfo() {
|
||||
function proceedFromInfo() {
|
||||
state.error = null;
|
||||
if (state.info.didType === "web-external") {
|
||||
state.step = "key-choice";
|
||||
@@ -130,7 +130,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInitialDidDoc() {
|
||||
function confirmInitialDidDoc() {
|
||||
state.step = "creating";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
let currentPath = $state(
|
||||
getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"),
|
||||
);
|
||||
const APP_BASE = "/app";
|
||||
|
||||
function getPathWithoutQuery(hash: string): string {
|
||||
const queryIndex = hash.indexOf("?");
|
||||
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
|
||||
function getAppPath(): string {
|
||||
const pathname = globalThis.location.pathname;
|
||||
if (pathname.startsWith(APP_BASE)) {
|
||||
const path = pathname.slice(APP_BASE.length) || "/";
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
globalThis.addEventListener("hashchange", () => {
|
||||
currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/");
|
||||
let currentPath = $state(getAppPath());
|
||||
|
||||
globalThis.addEventListener("popstate", () => {
|
||||
currentPath = getAppPath();
|
||||
});
|
||||
|
||||
export function navigate(path: string) {
|
||||
currentPath = path;
|
||||
globalThis.location.hash = path;
|
||||
export function navigate(path: string, replace = false) {
|
||||
const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path);
|
||||
if (replace) {
|
||||
globalThis.history.replaceState(null, "", fullPath);
|
||||
} else {
|
||||
globalThis.history.pushState(null, "", fullPath);
|
||||
}
|
||||
currentPath = path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
export function getCurrentPath() {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
export function getFullUrl(path: string): string {
|
||||
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ServerConfigState {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let state = $state<ServerConfigState>({
|
||||
const state = $state<ServerConfigState>({
|
||||
serverName: null,
|
||||
primaryColor: null,
|
||||
primaryColorDark: null,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let actAsInProgress = $state(false)
|
||||
|
||||
function getDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('did')
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
const parData = await parResponse.json()
|
||||
if (parData.request_uri) {
|
||||
window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}`
|
||||
window.location.href = `/app/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}`
|
||||
} else {
|
||||
error = $_('actAs.invalidResponse')
|
||||
loading = false
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
{#if auth.session?.isAdmin}
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('admin.title')}</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('appPasswords.title')}</h1>
|
||||
</header>
|
||||
<p class="description">
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('comms.title')}</h1>
|
||||
<p class="description">{$_('comms.description')}</p>
|
||||
</header>
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('delegation.title')}</h1>
|
||||
</header>
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
|
||||
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
|
||||
{$_('delegation.actAs')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@
|
||||
<h2>{$_('delegation.auditLog')}</h2>
|
||||
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
|
||||
</div>
|
||||
<a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
|
||||
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -166,73 +166,73 @@
|
||||
|
||||
<nav class="nav-grid">
|
||||
{#if auth.session.status === 'migrated'}
|
||||
<a href="#/did-document" class="nav-card migrated-card">
|
||||
<a href="/app/did-document" class="nav-card migrated-card">
|
||||
<h3>{$_('dashboard.navDidDocument')}</h3>
|
||||
<p>{$_('dashboard.navDidDocumentDesc')}</p>
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-card">
|
||||
<a href="/app/sessions" class="nav-card">
|
||||
<h3>{$_('dashboard.navSessions')}</h3>
|
||||
<p>{$_('dashboard.navSessionsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/security" class="nav-card">
|
||||
<a href="/app/security" class="nav-card">
|
||||
<h3>{$_('dashboard.navSecurity')}</h3>
|
||||
<p>{$_('dashboard.navSecurityDesc')}</p>
|
||||
</a>
|
||||
<a href="#/settings" class="nav-card">
|
||||
<a href="/app/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<a href="/app/migrate" class="nav-card">
|
||||
<h3>{$_('dashboard.navMigrateAgain')}</h3>
|
||||
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="#/app-passwords" class="nav-card">
|
||||
<a href="/app/app-passwords" class="nav-card">
|
||||
<h3>{$_('dashboard.navAppPasswords')}</h3>
|
||||
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-card">
|
||||
<a href="/app/sessions" class="nav-card">
|
||||
<h3>{$_('dashboard.navSessions')}</h3>
|
||||
<p>{$_('dashboard.navSessionsDesc')}</p>
|
||||
</a>
|
||||
{#if inviteCodesEnabled && auth.session.isAdmin}
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<a href="/app/invite-codes" class="nav-card">
|
||||
<h3>{$_('dashboard.navInviteCodes')}</h3>
|
||||
<p>{$_('dashboard.navInviteCodesDesc')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/settings" class="nav-card">
|
||||
<a href="/app/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/security" class="nav-card">
|
||||
<a href="/app/security" class="nav-card">
|
||||
<h3>{$_('dashboard.navSecurity')}</h3>
|
||||
<p>{$_('dashboard.navSecurityDesc')}</p>
|
||||
</a>
|
||||
<a href="#/comms" class="nav-card">
|
||||
<a href="/app/comms" class="nav-card">
|
||||
<h3>{$_('dashboard.navComms')}</h3>
|
||||
<p>{$_('dashboard.navCommsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/repo" class="nav-card">
|
||||
<a href="/app/repo" class="nav-card">
|
||||
<h3>{$_('dashboard.navRepo')}</h3>
|
||||
<p>{$_('dashboard.navRepoDesc')}</p>
|
||||
</a>
|
||||
<a href="#/controllers" class="nav-card">
|
||||
<a href="/app/controllers" class="nav-card">
|
||||
<h3>{$_('dashboard.navDelegation')}</h3>
|
||||
<p>{$_('dashboard.navDelegationDesc')}</p>
|
||||
</a>
|
||||
{#if isDidWeb}
|
||||
<a href="#/did-document" class="nav-card did-web-card">
|
||||
<a href="/app/did-document" class="nav-card did-web-card">
|
||||
<h3>{$_('dashboard.navDidDocument')}</h3>
|
||||
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<a href="/app/migrate" class="nav-card">
|
||||
<h3>{$_('migration.navTitle')}</h3>
|
||||
<p>{$_('migration.navDesc')}</p>
|
||||
</a>
|
||||
{#if auth.session.isAdmin}
|
||||
<a href="#/admin" class="nav-card admin-card">
|
||||
<a href="/app/admin" class="nav-card admin-card">
|
||||
<h3>{$_('dashboard.navAdmin')}</h3>
|
||||
<p>{$_('dashboard.navAdminDesc')}</p>
|
||||
</a>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a>
|
||||
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
|
||||
<h1>{$_('delegation.auditLogTitle')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('didEditor.title')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
<script lang="ts">
|
||||
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)
|
||||
|
||||
const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto']
|
||||
const wordSpacing: Record<string, string> = {
|
||||
'Bluesky': '0.01em',
|
||||
'Tangled': '0.02em',
|
||||
'Leaflet': '0.05em',
|
||||
'ATProto': '0',
|
||||
}
|
||||
let currentWordIndex = $state(0)
|
||||
let isTransitioning = $state(false)
|
||||
let currentWord = $derived(heroWords[currentWordIndex])
|
||||
let currentSpacing = $derived(wordSpacing[currentWord] || '0')
|
||||
|
||||
onMount(() => {
|
||||
api.describeServer().then(info => {
|
||||
if (info.availableUserDomains?.length) {
|
||||
pdsHostname = info.availableUserDomains[0]
|
||||
}
|
||||
if (info.version) {
|
||||
pdsVersion = info.version
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
const baseDuration = 2000
|
||||
let wordTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function cycleWord() {
|
||||
isTransitioning = true
|
||||
setTimeout(() => {
|
||||
currentWordIndex = (currentWordIndex + 1) % heroWords.length
|
||||
isTransitioning = false
|
||||
const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration
|
||||
wordTimeout = setTimeout(cycleWord, duration)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
wordTimeout = setTimeout(cycleWord, baseDuration)
|
||||
|
||||
api.listRepos(1000).then(data => {
|
||||
userCount = data.repos.length
|
||||
}).catch(() => {})
|
||||
|
||||
const pattern = document.getElementById('dotPattern')
|
||||
if (!pattern) return
|
||||
|
||||
const spacing = 32
|
||||
const cols = Math.ceil((window.innerWidth + 600) / spacing)
|
||||
const rows = Math.ceil((window.innerHeight + 100) / spacing)
|
||||
const dots: { el: HTMLElement; x: number; y: number }[] = []
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const dot = document.createElement('div')
|
||||
dot.className = 'dot'
|
||||
dot.style.left = (x * spacing) + 'px'
|
||||
dot.style.top = (y * spacing) + 'px'
|
||||
pattern.appendChild(dot)
|
||||
dots.push({ el: dot, x: x * spacing, y: y * spacing })
|
||||
}
|
||||
}
|
||||
|
||||
let mouseX = -1000
|
||||
let mouseY = -1000
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mouseX = e.clientX
|
||||
mouseY = e.clientY
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
let animationId: number
|
||||
|
||||
function updateDots() {
|
||||
const patternRect = pattern.getBoundingClientRect()
|
||||
dots.forEach(dot => {
|
||||
const dotX = patternRect.left + dot.x + 5
|
||||
const dotY = patternRect.top + dot.y + 5
|
||||
const dist = Math.hypot(mouseX - dotX, mouseY - dotY)
|
||||
const maxDist = 120
|
||||
const scale = Math.min(1, Math.max(0.1, dist / maxDist))
|
||||
dot.el.style.transform = `scale(${scale})`
|
||||
})
|
||||
animationId = requestAnimationFrame(updateDots)
|
||||
}
|
||||
updateDots()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
cancelAnimationFrame(animationId)
|
||||
clearTimeout(wordTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="pattern-container">
|
||||
<div class="pattern" id="dotPattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<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">
|
||||
<section class="hero">
|
||||
<h1>A home for your <span class="cycling-word-container"><span class="cycling-word" class:transitioning={isTransitioning} style="letter-spacing: {currentSpacing}">{currentWord}</span></span> account</h1>
|
||||
|
||||
<p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if auth.session}
|
||||
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
|
||||
{:else}
|
||||
<a href="#/register" class="btn primary">Join This Server</a>
|
||||
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<p>"Nature does not hurry, yet everything is accomplished."</p>
|
||||
<cite>Lao Tzu</cite>
|
||||
</blockquote>
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
<h2>What you get</h2>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>Real security</h3>
|
||||
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Your own identity</h3>
|
||||
<p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Stay in the loop</h3>
|
||||
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>You decide what apps can do</h3>
|
||||
<p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>App passwords with guardrails</h3>
|
||||
<p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Delegate without sharing passwords</h3>
|
||||
<p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Automatic backups</h3>
|
||||
<p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Everything in one place</h2>
|
||||
|
||||
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
|
||||
|
||||
<h2>Works with everything</h2>
|
||||
|
||||
<p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p>
|
||||
|
||||
<h2>Ready to try it?</h2>
|
||||
|
||||
<p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if auth.session}
|
||||
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
|
||||
{:else}
|
||||
<a href="#/register" class="btn primary">Join This Server</a>
|
||||
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Made by people who don't take themselves too seriously</span>
|
||||
<span>Open Source: issues & PRs welcome</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pattern-container {
|
||||
position: fixed;
|
||||
top: -32px;
|
||||
left: -32px;
|
||||
right: -32px;
|
||||
bottom: -32px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% + 500px);
|
||||
height: 100%;
|
||||
animation: drift 80s linear infinite;
|
||||
}
|
||||
|
||||
.pattern :global(.dot) {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.04s linear;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pattern :global(.dot) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translateX(-500px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: var(--accent);
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--text-inverse);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hostname.placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.user-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.85;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.user-count {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: 72px 32px 32px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--space-7) 0 var(--space-8);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
margin-bottom: var(--space-6);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cycling-word-container {
|
||||
display: inline-block;
|
||||
width: 3.9em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cycling-word {
|
||||
display: inline-block;
|
||||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.cycling-word.transitioning {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--secondary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--secondary-hover);
|
||||
border-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: var(--secondary-muted);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: var(--space-8) 0 0 0;
|
||||
padding: var(--space-6);
|
||||
background: var(--accent-muted);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
blockquote cite {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-light);
|
||||
margin: var(--space-8) 0 var(--space-5);
|
||||
}
|
||||
|
||||
.content h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content > p {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-5);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-6);
|
||||
margin: var(--space-6) 0 var(--space-8);
|
||||
}
|
||||
|
||||
.feature {
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.feature p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-count,
|
||||
.nav-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: var(--space-9);
|
||||
padding-top: var(--space-7);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -87,7 +87,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('inviteCodes.title')}</h1>
|
||||
</header>
|
||||
<p class="description">
|
||||
|
||||
@@ -161,13 +161,13 @@
|
||||
</button>
|
||||
|
||||
<p class="forgot-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<a href="/app/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<span class="separator">·</span>
|
||||
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
<a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
|
||||
{$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,22 @@
|
||||
if (errorParam) {
|
||||
oauthCallbackProcessed = true
|
||||
oauthError = errorDescription || errorParam
|
||||
window.history.replaceState({}, '', '/#/migrate')
|
||||
window.history.replaceState({}, '', '/app/migrate')
|
||||
return
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
oauthCallbackProcessed = true
|
||||
window.history.replaceState({}, '', '/#/migrate')
|
||||
window.history.replaceState({}, '', '/app/migrate')
|
||||
direction = 'inbound'
|
||||
oauthLoading = true
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
|
||||
const stored = loadMigrationState()
|
||||
if (stored && stored.direction === 'inbound') {
|
||||
inboundFlow.resumeFromState(stored)
|
||||
}
|
||||
|
||||
inboundFlow.handleOAuthCallback(code, state)
|
||||
.then(() => {
|
||||
oauthLoading = false
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getChannel(): string {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('channel') || 'email'
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
let accounts = $state<AccountInfo[]>([])
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
let rememberChoice = $state(false)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
})
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getDelegatedDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('delegated_did')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
function getError(): string {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error') || 'Unknown error'
|
||||
}
|
||||
|
||||
function getErrorDescription(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error_description')
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
})
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getErrorFromUrl(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error')
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
</form>
|
||||
|
||||
<p class="help-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a> · <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
let autoStarted = $state(false)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let success = $state(false)
|
||||
|
||||
function getUrlParams(): { did: string | null; token: string | null } {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return {
|
||||
did: params.get('did'),
|
||||
token: params.get('token'),
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="#/migrate" class="migrate-link">
|
||||
<a href="/app/migrate" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
@@ -381,10 +381,10 @@
|
||||
|
||||
<div class="form-links">
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
|
||||
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
|
||||
</p>
|
||||
<p class="link-text">
|
||||
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
{$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a>
|
||||
{$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a>
|
||||
</p>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
{#if view !== 'collections'}
|
||||
<span class="sep">/</span>
|
||||
<button class="breadcrumb-link" onclick={goBack}>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('security.title')}</h1>
|
||||
</header>
|
||||
|
||||
@@ -722,7 +722,7 @@
|
||||
<p class="description">
|
||||
{$_('security.trustedDevicesDescription')}
|
||||
</p>
|
||||
<a href="#/trusted-devices" class="section-link">
|
||||
<a href="/app/trusted-devices" class="section-link">
|
||||
{$_('security.manageTrustedDevices')} →
|
||||
</a>
|
||||
</section>
|
||||
@@ -765,8 +765,8 @@
|
||||
<strong>{$_('security.legacyLoginWarning')}</strong>
|
||||
<p>{$_('security.totpPasswordWarning')}</p>
|
||||
<ol>
|
||||
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="/app/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="/app/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('sessions.title')}</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('settings.title')}</h1>
|
||||
</header>
|
||||
{#if message}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
|
||||
<a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
|
||||
<h1>{$_('trustedDevices.title')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -33,17 +33,10 @@
|
||||
|
||||
|
||||
function parseQueryParams() {
|
||||
const hash = window.location.hash
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex === -1) return {}
|
||||
|
||||
const queryString = hash.slice(queryIndex + 1)
|
||||
const params: Record<string, string> = {}
|
||||
for (const pair of queryString.split('&')) {
|
||||
const [key, value] = pair.split('=')
|
||||
if (key && value) {
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value)
|
||||
}
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -235,13 +228,13 @@
|
||||
<p class="subtitle">{$_('verify.emailUpdated')}</p>
|
||||
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
|
||||
<div class="actions">
|
||||
<a href="#/settings" class="btn">{$_('common.backToSettings')}</a>
|
||||
<a href="/app/settings" class="btn">{$_('common.backToSettings')}</a>
|
||||
</div>
|
||||
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
|
||||
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
|
||||
<p class="info-text">{$_('verify.canNowSignIn')}</p>
|
||||
<div class="actions">
|
||||
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
|
||||
<a href="/app/login" class="btn">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="subtitle">
|
||||
@@ -259,7 +252,7 @@
|
||||
{#if !auth.session}
|
||||
<div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div>
|
||||
<div class="actions">
|
||||
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
|
||||
<a href="/app/login" class="btn">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
@@ -301,7 +294,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/settings">{$_('common.backToSettings')}</a>
|
||||
<a href="/app/settings">{$_('common.backToSettings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{:else if mode === 'token'}
|
||||
@@ -356,7 +349,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
{:else if pendingVerification}
|
||||
<h1>{$_('verify.title')}</h1>
|
||||
@@ -399,7 +392,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
|
||||
<a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
|
||||
</p>
|
||||
{:else}
|
||||
<h1>{$_('verify.title')}</h1>
|
||||
@@ -407,8 +400,8 @@
|
||||
<p class="info-text">{$_('verify.noPendingInfo')}</p>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#/register" class="btn">{$_('verify.createAccount')}</a>
|
||||
<a href="#/login" class="btn secondary">{$_('verify.signIn')}</a>
|
||||
<a href="/app/register" class="btn">{$_('verify.createAccount')}</a>
|
||||
<a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("AppPasswords", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("AppPasswords", () => {
|
||||
screen.getByRole("heading", { name: /app passwords/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -50,11 +50,14 @@ describe("AppPasswords", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows loading text while fetching passwords", async () => {
|
||||
mockEndpoint("com.atproto.server.listAppPasswords", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({ passwords: [] });
|
||||
});
|
||||
it("shows loading text while fetching passwords", () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
|
||||
),
|
||||
);
|
||||
render(AppPasswords);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("Comms", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("Comms", () => {
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByRole("heading", { name: /preferred channel/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /channel configuration/i }))
|
||||
@@ -71,11 +71,17 @@ describe("Comms", () => {
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("shows loading text while fetching preferences", async () => {
|
||||
mockEndpoint("_account.getNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
it("shows loading text while fetching preferences", () => {
|
||||
mockEndpoint(
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve(jsonResponse(mockData.notificationPrefs())),
|
||||
100,
|
||||
)
|
||||
),
|
||||
);
|
||||
render(Comms);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("Dashboard", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("shows loading state while checking auth", () => {
|
||||
@@ -61,10 +61,10 @@ describe("Dashboard", () => {
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
const navCards = [
|
||||
{ name: /app passwords/i, href: "#/app-passwords" },
|
||||
{ name: /account settings/i, href: "#/settings" },
|
||||
{ name: /communication preferences/i, href: "#/comms" },
|
||||
{ name: /repository explorer/i, href: "#/repo" },
|
||||
{ name: /app passwords/i, href: "/app/app-passwords" },
|
||||
{ name: /account settings/i, href: "/app/settings" },
|
||||
{ name: /communication preferences/i, href: "/app/comms" },
|
||||
{ name: /repository explorer/i, href: "/app/repo" },
|
||||
];
|
||||
for (const { name, href } of navCards) {
|
||||
const card = screen.getByRole("link", { name });
|
||||
@@ -84,7 +84,7 @@ describe("Dashboard", () => {
|
||||
await waitFor(() => {
|
||||
const inviteCard = screen.getByRole("link", { name: /invite codes/i });
|
||||
expect(inviteCard).toBeInTheDocument();
|
||||
expect(inviteCard).toHaveAttribute("href", "#/invite-codes");
|
||||
expect(inviteCard).toHaveAttribute("href", "/app/invite-codes");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,12 +92,12 @@ describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()));
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
|
||||
mockEndpoint("/oauth/revoke", () => jsonResponse({}));
|
||||
});
|
||||
it("calls deleteSession and navigates to login on logout", async () => {
|
||||
let deleteSessionCalled = false;
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => {
|
||||
deleteSessionCalled = true;
|
||||
it("calls oauth revoke and navigates to login on logout", async () => {
|
||||
let revokeCalled = false;
|
||||
mockEndpoint("/oauth/revoke", () => {
|
||||
revokeCalled = true;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Dashboard);
|
||||
@@ -112,8 +112,8 @@ describe("Dashboard", () => {
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
|
||||
await waitFor(() => {
|
||||
expect(deleteSessionCalled).toBe(true);
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(revokeCalled).toBe(true);
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("clears session from localStorage after logout", async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
globalThis.location.hash = "";
|
||||
mockEndpoint(
|
||||
"/oauth/par",
|
||||
() => jsonResponse({ request_uri: "urn:mock:request" }),
|
||||
@@ -47,7 +46,7 @@ describe("Login", () => {
|
||||
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute(
|
||||
"href",
|
||||
"#/register",
|
||||
"/app/register",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,9 +55,9 @@ describe("Login", () => {
|
||||
render(Login);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /forgot password/i }))
|
||||
.toHaveAttribute("href", "#/reset-password");
|
||||
.toHaveAttribute("href", "/app/reset-password");
|
||||
expect(screen.getByRole("link", { name: /lost passkey/i }))
|
||||
.toHaveAttribute("href", "#/request-passkey-recovery");
|
||||
.toHaveAttribute("href", "/app/request-passkey-recovery");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -122,7 +121,7 @@ describe("Login", () => {
|
||||
await fireEvent.click(aliceAccount);
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/dashboard");
|
||||
expect(globalThis.location.pathname).toBe("/app/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,13 +162,13 @@ describe("Login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows verification form when pending verification exists", async () => {
|
||||
it("shows verification form when pending verification exists", () => {
|
||||
render(Login);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("shows loading state while auth is initializing", async () => {
|
||||
it("shows loading state while auth is initializing", () => {
|
||||
_testSetState({
|
||||
session: null,
|
||||
loading: true,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("Settings", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ describe("Settings", () => {
|
||||
screen.getByRole("heading", { name: /account settings/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByRole("heading", { name: /change email/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /change handle/i }))
|
||||
@@ -463,7 +463,7 @@ describe("Settings", () => {
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("shows cancel button to return to request state", async () => {
|
||||
|
||||
@@ -263,7 +263,7 @@ describe("migration/atproto-client", () => {
|
||||
describe("getMigrationOAuthRedirectUri", () => {
|
||||
it("returns migrate path based on origin", () => {
|
||||
const redirectUri = getMigrationOAuthRedirectUri();
|
||||
expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`);
|
||||
expect(redirectUri).toBe(`${globalThis.location.origin}/app/migrate`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+55
-14
@@ -1,6 +1,44 @@
|
||||
import { vi } from "vitest";
|
||||
import type { AppPassword, InviteCode, Session } from "../lib/api";
|
||||
import { _testSetState } from "../lib/auth.svelte";
|
||||
|
||||
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
|
||||
const originalReplaceState = globalThis.history.replaceState.bind(
|
||||
globalThis.history,
|
||||
);
|
||||
|
||||
globalThis.history.pushState = (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => {
|
||||
originalPushState(data, unused, url);
|
||||
if (url) {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: urlStr.split("?")[0],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.history.replaceState = (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => {
|
||||
originalReplaceState(data, unused, url);
|
||||
if (url) {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: urlStr.split("?")[0],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export interface MockResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
@@ -49,23 +87,26 @@ export function setupFetchMock(): void {
|
||||
clone: () => ({ ...result }) as Response,
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: "Not Found",
|
||||
@@ -76,9 +117,9 @@ export function setupFetchMock(): void {
|
||||
},
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
@@ -87,7 +128,7 @@ export function jsonResponse<T>(data: T, status = 200): MockResponse {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
json: () => Promise.resolve(data),
|
||||
};
|
||||
}
|
||||
export function errorResponse(
|
||||
@@ -98,7 +139,7 @@ export function errorResponse(
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error, message }),
|
||||
json: () => Promise.resolve({ error, message }),
|
||||
};
|
||||
}
|
||||
export const mockData = {
|
||||
|
||||
Reference in New Issue
Block a user