263748af4a
Add canva/canda-compatible routes for lightweight project open/save and agent thread history: - POST /canva/project/queryProject, saveProject - POST /canva/agent/queryAgentLastThread, agentThreadList - GET /canda/chat-history, /canda/thread/status Canvas save now accepts a compressed SHAKKERDATA:// snapshot payload, preserves connections, and returns a SaveProject response; blank successful image nodes are backfilled from generated artifacts. Frontend designGateway adopts the new save/query flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
|
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
|
|
|
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
|
const accessTokenCookie = "usertoken";
|
|
const refreshTokenCookie = "refreshToken";
|
|
|
|
async function request<T>(path: string, body: unknown): Promise<T> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
} catch (err) {
|
|
throw createNetworkFailure("auth", err);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw createApiFailure("auth", response, payload);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async function get<T>(path: string): Promise<T> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(path, { cache: "no-store" });
|
|
} catch (err) {
|
|
throw createNetworkFailure("auth", err);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw createApiFailure("auth", response, payload);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const headers = new Headers(init.headers);
|
|
if (!(init.body instanceof FormData)) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value));
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(path, {
|
|
...init,
|
|
headers,
|
|
cache: "no-store"
|
|
});
|
|
} catch (err) {
|
|
throw createNetworkFailure("auth", err);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw createApiFailure("auth", response, payload);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export const authGateway = {
|
|
options() {
|
|
return get<AuthOptions>("/api/auth/options");
|
|
},
|
|
|
|
requestGlobalEmailCode(email: string, turnstileToken: string) {
|
|
return request<AuthCodeResponse>("/api/auth/global/email-code", { email, turnstileToken });
|
|
},
|
|
|
|
loginGlobalEmailCode(email: string, code: string) {
|
|
return request<AuthSession>("/api/auth/global/email-login", { email, code });
|
|
},
|
|
|
|
loginGlobalGoogle(idToken: string) {
|
|
return request<AuthSession>("/api/auth/global/google-login", { idToken });
|
|
},
|
|
|
|
profile() {
|
|
return apiRequest<{ user: AuthUser }>("/api/account/profile");
|
|
},
|
|
|
|
updateProfile(userPatch: Partial<Pick<AuthUser, "name" | "avatarUrl">>) {
|
|
return apiRequest<{ user: AuthUser }>("/api/account/profile", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(userPatch)
|
|
});
|
|
},
|
|
|
|
devices() {
|
|
return apiRequest<{ devices: AccountDevice[] }>("/api/account/devices");
|
|
},
|
|
|
|
removeDevices() {
|
|
return apiRequest<{ removed: number }>("/api/account/devices", {
|
|
method: "DELETE"
|
|
});
|
|
},
|
|
|
|
logoutAccount() {
|
|
return apiRequest<{ removed: number }>("/api/account/logout", {
|
|
method: "POST",
|
|
body: JSON.stringify({})
|
|
});
|
|
},
|
|
|
|
async uploadAvatar(file: File) {
|
|
const upload = await apiRequest<{
|
|
uploadUrl: string;
|
|
publicUrl: string;
|
|
headers: Record<string, string>;
|
|
}>("/api/assets/upload-url", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
fileName: file.name,
|
|
contentType: file.type || "application/octet-stream",
|
|
size: file.size
|
|
})
|
|
});
|
|
|
|
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
|
|
await fetch(upload.uploadUrl, {
|
|
method: "PUT",
|
|
headers: upload.headers,
|
|
body: file
|
|
});
|
|
}
|
|
return upload.publicUrl;
|
|
}
|
|
};
|
|
|
|
export function readStoredAuthSession(): StoredAuthSession | null {
|
|
if (typeof window === "undefined") return null;
|
|
const raw = window.localStorage.getItem(sessionStorageKey);
|
|
if (!raw) return null;
|
|
try {
|
|
const session = JSON.parse(raw) as StoredAuthSession;
|
|
if (!session.accessToken || isSessionExpired(session)) {
|
|
clearStoredAuthSession();
|
|
return null;
|
|
}
|
|
return session;
|
|
} catch {
|
|
clearStoredAuthSession();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function storeAuthSession(session: AuthSession): StoredAuthSession {
|
|
const stored = { ...session, savedAt: Date.now() };
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
|
|
setCookie(accessTokenCookie, session.accessToken, session.expiresIn);
|
|
if (session.refreshToken && session.refreshExpiresIn) {
|
|
setCookie(refreshTokenCookie, session.refreshToken, session.refreshExpiresIn);
|
|
}
|
|
}
|
|
return stored;
|
|
}
|
|
|
|
export function updateStoredAuthUser(userPatch: Partial<AuthUser>): StoredAuthSession | null {
|
|
const current = readStoredAuthSession();
|
|
if (!current) return null;
|
|
|
|
const stored = {
|
|
...current,
|
|
user: {
|
|
...current.user,
|
|
...userPatch
|
|
}
|
|
};
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
|
|
}
|
|
return stored;
|
|
}
|
|
|
|
export function clearStoredAuthSession() {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.removeItem(sessionStorageKey);
|
|
expireCookie(accessTokenCookie);
|
|
expireCookie(refreshTokenCookie);
|
|
}
|
|
|
|
export function getAuthAccessToken() {
|
|
return readStoredAuthSession()?.accessToken ?? "";
|
|
}
|
|
|
|
export function authHeaders() {
|
|
const token = getAuthAccessToken();
|
|
if (!token) return {};
|
|
return {
|
|
Authorization: `Bearer ${token}`,
|
|
token
|
|
} satisfies Record<string, string>;
|
|
}
|
|
|
|
function isSessionExpired(session: StoredAuthSession) {
|
|
const expiresIn = Number(session.expiresIn || 0);
|
|
return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000;
|
|
}
|
|
|
|
function setCookie(name: string, value: string, maxAge: number) {
|
|
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Lax`;
|
|
}
|
|
|
|
function expireCookie(name: string) {
|
|
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
}
|