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>
161 lines
4.1 KiB
Vue
161 lines
4.1 KiB
Vue
<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>
|