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,310 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "Teilen" for the open document. Sharing needs the document on the server,
|
||||
* which needs an account: a local document is uploaded first (after
|
||||
* signing in). The owner then manages links ("anyone with the link can
|
||||
* view / edit") and people (invited by e-mail, with a role).
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { api, type ShareInfo } from '../io/api';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
const toasts = useToasts();
|
||||
|
||||
const info = ref<ShareInfo | null>(null);
|
||||
const busy = ref(false);
|
||||
const inviteEmail = ref('');
|
||||
const inviteRole = ref<'viewer' | 'editor'>('editor');
|
||||
|
||||
const current = computed(() => library.state.current);
|
||||
const isServer = computed(() => current.value?.source === 'server');
|
||||
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
|
||||
const docPath = computed(() => `/documents/${current.value?.id}`);
|
||||
|
||||
async function load() {
|
||||
info.value = null;
|
||||
if (!isOwner.value) return;
|
||||
try {
|
||||
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.open, current.value?.id, library.currentRole.value] as const,
|
||||
([o]) => {
|
||||
if (o) void load();
|
||||
},
|
||||
);
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
busy.value = true;
|
||||
try {
|
||||
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
|
||||
return true;
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
|
||||
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
|
||||
|
||||
async function copy(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlFor(token));
|
||||
toasts.success('Link kopiert.');
|
||||
} catch {
|
||||
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
|
||||
}
|
||||
}
|
||||
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
|
||||
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
|
||||
if (await call('POST', '/links', { role })) {
|
||||
const l = linkFor(role);
|
||||
if (l) await copy(l.token);
|
||||
}
|
||||
}
|
||||
async function revoke(id: number) {
|
||||
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
|
||||
await call('DELETE', `/links/${id}`);
|
||||
}
|
||||
}
|
||||
async function invite() {
|
||||
const email = inviteEmail.value.trim();
|
||||
if (!email) return;
|
||||
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
|
||||
toasts.success(`Einladung an ${email} verschickt.`);
|
||||
inviteEmail.value = '';
|
||||
}
|
||||
}
|
||||
async function setRole(userId: number, role: string) {
|
||||
await call('PATCH', `/members/${userId}`, { role });
|
||||
}
|
||||
async function removeMember(userId: number, name: string) {
|
||||
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
|
||||
}
|
||||
|
||||
async function uploadAndShare() {
|
||||
const id = current.value?.id;
|
||||
if (!id) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await library.upload([id]);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const LINK_ROWS = [
|
||||
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
|
||||
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
|
||||
<!-- local document -->
|
||||
<template v-if="!isServer">
|
||||
<p v-if="!session.signedIn.value" class="lead">
|
||||
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
|
||||
Konto hochgeladen.
|
||||
</p>
|
||||
<p v-else class="lead">
|
||||
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
|
||||
dem Browser entfernt).
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- server document, not the owner -->
|
||||
<p v-else-if="!isOwner" class="lead">
|
||||
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
|
||||
</p>
|
||||
|
||||
<!-- owner -->
|
||||
<template v-else-if="info">
|
||||
<h3>Link</h3>
|
||||
<ul class="rows">
|
||||
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
|
||||
<span class="label">{{ r.label }}</span>
|
||||
<template v-if="linkFor(r.role)">
|
||||
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
|
||||
<span class="actions">
|
||||
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
|
||||
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
|
||||
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="actions">
|
||||
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="hint">Links funktionieren auch ohne Konto.</p>
|
||||
|
||||
<h3>Personen</h3>
|
||||
<form class="invite" @submit.prevent="invite">
|
||||
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
|
||||
<select v-model="inviteRole">
|
||||
<option value="editor">kann bearbeiten</option>
|
||||
<option value="viewer">kann ansehen</option>
|
||||
</select>
|
||||
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
|
||||
</form>
|
||||
<ul class="rows">
|
||||
<li class="person">
|
||||
<span class="who">
|
||||
<strong>{{ session.state.user?.name }}</strong>
|
||||
<span class="dim">{{ session.state.user?.email }}</span>
|
||||
</span>
|
||||
<span class="dim">Besitzer</span>
|
||||
</li>
|
||||
<li v-for="m in info.members" :key="m.user_id" class="person">
|
||||
<span class="who">
|
||||
<strong>{{ m.name }}</strong>
|
||||
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
|
||||
</span>
|
||||
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="editor">kann bearbeiten</option>
|
||||
<option value="viewer">kann ansehen</option>
|
||||
</select>
|
||||
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
|
||||
</li>
|
||||
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
|
||||
<span class="who">
|
||||
<strong>{{ i.email }}</strong>
|
||||
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else class="lead">Lade …</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Schließen</button>
|
||||
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
|
||||
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
|
||||
Hochladen und teilen
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
h3:not(:first-child) {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.link-row,
|
||||
.person {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.label {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.url {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.invite input {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.invite input,
|
||||
select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user