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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
* code. Clicking the link signs in (in whatever browser opens it); typing
* the code here signs in this browser. Both create the account on first use.
*/
import { nextTick, ref, watch } from 'vue';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean; reason?: string }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const session = useSession();
const step = ref<'email' | 'code'>('email');
const email = ref('');
const code = ref('');
const busy = ref(false);
const error = ref<string | null>(null);
const emailInput = ref<HTMLInputElement | null>(null);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
async (o) => {
if (!o) return;
step.value = 'email';
code.value = '';
error.value = null;
await nextTick();
emailInput.value?.focus();
},
);
async function send() {
if (!email.value.trim()) return;
busy.value = true;
error.value = null;
try {
await session.requestLink(email.value.trim());
step.value = 'code';
await nextTick();
codeInput.value?.focus();
} catch (err) {
error.value = (err as Error).message.includes('422')
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
: (err as Error).message;
} finally {
busy.value = false;
}
}
async function verify() {
busy.value = true;
error.value = null;
try {
await session.verifyCode(email.value.trim(), code.value);
} catch {
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
<template v-if="step === 'email'">
<p v-if="reason" class="reason">{{ reason }}</p>
<p class="lead">
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
</p>
<form @submit.prevent="send">
<label class="row">
E-Mail
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
</label>
</form>
</template>
<template v-else>
<p class="lead">
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
den 6-stelligen Code ein.
</p>
<form @submit.prevent="verify">
<label class="row">
Code
<input
ref="codeInput"
v-model="code"
class="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="7"
placeholder="123456"
/>
</label>
</form>
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
</template>
<p v-if="error" class="error">{{ error }}</p>
<template #footer>
<button @click="emit('close')">Abbrechen</button>
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
Link senden
</button>
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
Anmelden
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead,
.reason {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.5;
}
.reason {
font-weight: 600;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font: inherit;
font-size: 14px;
background: var(--surface-2);
color: var(--text);
}
.code {
letter-spacing: 0.25em;
font-variant-numeric: tabular-nums;
}
.linkish {
margin-top: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 12.5px;
color: var(--accent);
cursor: pointer;
}
.error {
margin: 10px 0 0;
font-size: 12.5px;
color: #dc2626;
}
</style>