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,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>
|
||||
Reference in New Issue
Block a user