Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The numolus server API (Rails, /api). JSON in and out, the session cookie
|
||||
* for the signed-in user, the CSRF token from the page's meta tag, and two
|
||||
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
|
||||
* and X-Share-Token (a share link's token, for access via link).
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: Record<string, unknown>,
|
||||
) {
|
||||
super(ApiError.describe(status, body));
|
||||
}
|
||||
static describe(status: number, body: Record<string, unknown>): string {
|
||||
if (status === 0) return 'Keine Verbindung zum Server.';
|
||||
if (status === 401) return 'Nicht angemeldet.';
|
||||
if (status === 404) return 'Nicht gefunden oder kein Zugriff.';
|
||||
if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.';
|
||||
const msgs = body.messages;
|
||||
if (Array.isArray(msgs) && msgs.length) return msgs.join(', ');
|
||||
return `Serverfehler (${status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Role = 'owner' | 'editor' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ServerDocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
version: number;
|
||||
role: Role;
|
||||
owner_name: string;
|
||||
updated_at: string;
|
||||
updated_by_name: string | null;
|
||||
}
|
||||
|
||||
export interface ServerDoc extends ServerDocMeta {
|
||||
content?: string;
|
||||
lock?: { locked_by_other: boolean; holder_name: string | null };
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[];
|
||||
members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[];
|
||||
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
|
||||
}
|
||||
|
||||
/** One id per browser tab, kept across reloads of that tab. */
|
||||
export const clientId: string = (() => {
|
||||
const key = 'numolus:client-id';
|
||||
try {
|
||||
const existing = sessionStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
sessionStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
})();
|
||||
|
||||
function csrfToken(): string {
|
||||
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
}
|
||||
|
||||
interface RequestOpts {
|
||||
body?: unknown;
|
||||
shareToken?: string | null;
|
||||
/** survives the page being closed (small bodies only, < 64 KB) */
|
||||
keepalive?: boolean;
|
||||
}
|
||||
|
||||
export async function api<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': csrfToken(),
|
||||
'X-Client-Id': clientId,
|
||||
};
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
credentials: 'same-origin',
|
||||
keepalive: opts.keepalive,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, {});
|
||||
}
|
||||
if (res.status === 204) return {} as T;
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) throw new ApiError(res.status, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** Data the server put into the page (see app/views/spa/show.html.erb). */
|
||||
export function bootData(): { user: User | null } {
|
||||
try {
|
||||
const el = document.getElementById('numolus-boot');
|
||||
return el ? JSON.parse(el.textContent || '{}') : { user: null };
|
||||
} catch {
|
||||
return { user: null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user