feat: migrate from Directus direct → API server (api.hejyou.com)

- directus.js: all calls now go to /languparent/* on api.hejyou.com
- Admin token and Directus URL no longer in the browser
- JWT issued by API server (30 days), stored in localStorage
- register: single call returns userId, profile step gets fresh JWT
- login: handles needsProfile flag (403) cleanly
- assetUrl: proxy via /languparent/assets/:fileId (no token in URL)
- .env.production: VITE_DIRECTUS_URL → VITE_API_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:43:36 +02:00
parent 0730fe6e8e
commit bccec179a4
5 changed files with 108 additions and 217 deletions

View File

@@ -1 +1 @@
VITE_DIRECTUS_URL=https://db.hejyou.com VITE_API_URL=https://api.hejyou.com

View File

@@ -1,98 +1,78 @@
const BASE = import.meta.env.VITE_DIRECTUS_URL const BASE = import.meta.env.VITE_API_URL
const json = { 'Content-Type': 'application/json' } const json = { 'Content-Type': 'application/json' }
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }) const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
// ── Auth ──────────────────────────────────────────────────────────────────────
export async function login(email, password) { export async function login(email, password) {
const res = await fetch(`${BASE}/auth/login`, { const res = await fetch(`${BASE}/languparent/auth/login`, {
method: 'POST', method: 'POST', headers: json,
headers: json,
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.') // Kein Profil vorhanden → UI zum Profil-Setup leiten
return data.data if (res.status === 403 && data.needsProfile) {
return { access_token: null, needsProfile: true, userId: data.userId }
}
if (!res.ok) throw new Error(data.error || 'Login fehlgeschlagen.')
return { access_token: data.token }
} }
export async function getMe(userToken) { export async function getMe(userToken) {
const res = await fetch(`${BASE}/users/me?fields=id,username,language_native,language_target`, { const res = await fetch(`${BASE}/languparent/auth/me`, {
headers: auth(userToken), headers: auth(userToken),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.') if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
return data.data return data // bereits vom API-Server entpackt
} }
// Nutzt den öffentlichen Registrierungsendpunkt — kein Admin-Token nötig // Registriert den Directus-User; gibt { userId } zurück
export async function registerUser(email, password) { export async function registerUser(email, password) {
const res = await fetch(`${BASE}/users/register`, { const res = await fetch(`${BASE}/languparent/auth/register`, {
method: 'POST', method: 'POST', headers: json,
headers: json,
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
if (res.status === 204) return null
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.') if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
return data.data return data // { userId }
} }
export async function checkUsername(username, userToken) { export async function checkUsername(username /*, _userToken — nicht mehr nötig */) {
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
const res = await fetch( const res = await fetch(
`${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`, `${BASE}/languparent/auth/check-username?username=${encodeURIComponent(username)}`,
{ headers: auth(userToken) }
) )
const data = await res.json() const data = await res.json()
return Array.isArray(data.data) && data.data.length === 0 if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
return data.available
} }
export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) { // Erstellt Profil über API-Server (Admin-Token bleibt server-seitig)
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '') // Gibt { token, expiresIn } zurück — muss via saveToken gespeichert werden
const h = auth(userToken) export async function createProfile({ userId, username, nativeLang, targetLang /*, userToken — ignoriert */ }) {
const res = await fetch(`${BASE}/languparent/auth/profile`, {
const profileRes = await fetch(`${BASE}/items/users_language`, { method: 'POST', headers: json,
method: 'POST', body: JSON.stringify({ userId, username, nativeLang, targetLang }),
headers: h,
body: JSON.stringify({ username_public: username, username_lowercases: clean }),
}) })
const profileData = await profileRes.json() const data = await res.json()
if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.') if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
const profileId = profileData.data.id return data // { token, expiresIn }
await fetch(`${BASE}/users/${userId}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
})
await fetch(`${BASE}/items/users_language/${profileId}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ user: userId }),
})
await fetch(`${BASE}/items/learning_pairs`, {
method: 'POST',
headers: h,
body: JSON.stringify({ user: profileId, language_from: nativeLang, language_to: targetLang, active: true, current_level: 1, points: 0 }),
})
return profileId
} }
// ── Sprachen ──────────────────────────────────────────────────────────────────
const LANG_META = { const LANG_META = {
de: { flag: '🇩🇪', speech: 'de-DE' }, de: { flag: '🇩🇪', speech: 'de-DE' },
en: { flag: '🇬🇧', speech: 'en-US' }, en: { flag: '🇬🇧', speech: 'en-US' },
se: { flag: '🇸🇪', speech: 'sv-SE' }, se: { flag: '🇸🇪', speech: 'sv-SE' },
} }
// Lädt Sprachen aus Directus (public, kein Token nötig)
export async function getLanguageOptions() { export async function getLanguageOptions() {
const res = await fetch( const res = await fetch(`${BASE}/languparent/languages`)
`${BASE}/items/language_options?filter[status][_eq]=published&fields=id,title_de,short&sort=title_en`
)
const data = await res.json() const data = await res.json()
return (data.data || []).map(l => ({ if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.')
return (data || []).map(l => ({
id: l.id, id: l.id,
label: l.title_de, label: l.title_de,
suffix: l.short, suffix: l.short,
@@ -104,167 +84,81 @@ export function langById(id, options) {
return (options || []).find(l => l.id === id) || null return (options || []).find(l => l.id === id) || null
} }
export async function getActiveLearningPair(profileId, userToken) { // ── Learning Pair ─────────────────────────────────────────────────────────────
const res = await fetch(
`${BASE}/items/learning_pairs?filter[user][_eq]=${profileId}&filter[active][_eq]=true` +
`&fields=id,language_from,language_to,current_level,points&limit=1`,
{ headers: auth(userToken) }
)
const data = await res.json()
return data.data?.[0] || null
}
export async function getWords(userToken, limit = 100) { export async function getActiveLearningPair(_profileId, userToken) {
const res = await fetch( // profileId steckt im JWT — der API-Server liest ihn von dort
`${BASE}/items/words?filter[status][_eq]=published` + const res = await fetch(`${BASE}/languparent/pair`, { headers: auth(userToken) })
`&fields=id,title_de,title_en,title_se,level&limit=${limit}`,
{ headers: auth(userToken) }
)
const data = await res.json()
return data.data || []
}
export async function getQuestions(userToken, limit = 100) {
const res = await fetch(
`${BASE}/items/questions?filter[status][_eq]=published` +
`&fields=id,question_de,question_en,question_se,answer_de,answer_en,answer_se,level,related_words.words_id` +
`&limit=${limit}`,
{ headers: auth(userToken) }
)
const data = await res.json()
return data.data || []
}
// Holt Progress für einen Nutzer, optional gefiltert auf eine Zielsprache
export async function getUserProgress(profileId, userToken, toLangId = null) {
let url = `${BASE}/items/user_progress?filter[user][_eq]=${profileId}` +
`&fields=id,word,question,card_type,result,language_from,language_to&limit=-1`
if (toLangId) url += `&filter[language_to][_eq]=${toLangId}`
const res = await fetch(url, { headers: auth(userToken) })
const data = await res.json()
return data.data || []
}
export async function saveProgress(payload, userToken) {
const res = await fetch(`${BASE}/items/user_progress`, {
method: 'POST',
headers: auth(userToken),
body: JSON.stringify(payload),
})
const data = await res.json() const data = await res.json()
if (!res.ok) return null if (!res.ok) return null
return data.data return data
} }
export async function addPointsToPair(pairId, newPoints, userToken) { export async function addPointsToPair(pairId, newPoints, userToken) {
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, { const res = await fetch(`${BASE}/languparent/pair/${pairId}/points`, {
method: 'PATCH', method: 'PATCH', headers: auth(userToken),
headers: auth(userToken),
body: JSON.stringify({ points: newPoints }), body: JSON.stringify({ points: newPoints }),
}) })
return res.ok return res.ok
} }
export async function addPointsToUser(userId, newTotal, userToken) { // ── Content ───────────────────────────────────────────────────────────────────
const res = await fetch(`${BASE}/users/${userId}`, {
method: 'PATCH', export async function getWords(userToken) {
headers: auth(userToken), const res = await fetch(`${BASE}/languparent/words`, { headers: auth(userToken) })
body: JSON.stringify({ points_total: newTotal }), const data = await res.json()
}) return data || []
return res.ok
} }
// Asset-URL inkl. User-Token, da Directus Hetzner-Storage standardmäßig nicht öffentlich ist export async function getQuestions(userToken) {
export function assetUrl(fileId, userToken) { const res = await fetch(`${BASE}/languparent/questions`, { headers: auth(userToken) })
if (!fileId) return null const data = await res.json()
const base = `${BASE}/assets/${fileId}` return data || []
return userToken ? `${base}?access_token=${encodeURIComponent(userToken)}` : base
} }
// Lädt qa_pairs (db_pairs) auf einem bestimmten Level und liefert pro Pair
// das zugehörige Bild + den fertig aufgelösten Statement-Satz zurück.
export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') { export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') {
const fields = [
'id',
'picture.picture',
'selections',
'word_main.db_words_id.id',
'word_main.db_words_id.titel_de',
'word_main.db_words_id.titel_en',
'word_main.db_words_id.titel_se',
'pairs.db_pairs_id.id',
'pairs.db_pairs_id.level',
'pairs.db_pairs_id.status',
'pairs.db_pairs_id.statement.db_statement_id.statement_de',
'pairs.db_pairs_id.statement.db_statement_id.statement_en',
'pairs.db_pairs_id.statement.db_statement_id.statement_se',
].join(',')
const filter = encodeURIComponent(
JSON.stringify({ pairs: { db_pairs_id: { level: { _eq: level } } } })
)
const res = await fetch( const res = await fetch(
`${BASE}/items/db_objects?fields=${fields}&filter=${filter}&limit=-1`, `${BASE}/languparent/qa-pairs?level=${level}&lang=${langSuffix}`,
{ headers: auth(userToken) } { headers: auth(userToken) },
) )
const data = await res.json() const data = await res.json()
if (!res.ok) return [] return data || []
const statementKey = `statement_${langSuffix}`
const titelKey = `titel_${langSuffix}`
const result = []
for (const obj of data.data || []) {
const wordsById = {}
let primaryWord = ''
for (const w of obj.word_main || []) {
const word = w.db_words_id
if (!word) continue
const text = word[titelKey] || word.titel_de || ''
if (text) wordsById[word.id] = text
if (!primaryWord && text) primaryWord = text
} }
for (const p of obj.pairs || []) { // ── Fortschritt ───────────────────────────────────────────────────────────────
const pair = p.db_pairs_id
if (!pair) continue
if (pair.level !== level) continue
if (pair.status === 'archived') continue
const stmtRow = pair.statement?.[0]?.db_statement_id export async function getUserProgress(_profileId, userToken, toLangId = null) {
if (!stmtRow) continue // profileId steckt im JWT — der API-Server filtert danach
const raw = stmtRow[statementKey] || stmtRow.statement_de || '' let url = `${BASE}/languparent/progress`
if (!raw) continue if (toLangId) url += `?lang=${toLangId}`
const res = await fetch(url, { headers: auth(userToken) })
const data = await res.json()
return data || []
}
const statement = raw.replace(/\{([^}]+)\}/g, (_m, ref) => { export async function saveProgress(payload, userToken) {
const parts = ref.split('.') const res = await fetch(`${BASE}/languparent/progress`, {
const wid = parts[parts.length - 1] method: 'POST', headers: auth(userToken),
return wordsById[wid] || primaryWord body: JSON.stringify(payload),
}) })
const data = await res.json()
result.push({ if (!res.ok) return null
pairId: pair.id, return data
level: pair.level,
statement,
pictureFileId: obj.picture?.picture || null,
objectId: obj.id,
primaryWord,
selections: obj.selections || null,
})
}
} }
return result // ── Assets ────────────────────────────────────────────────────────────────────
// Bilder via API-Server proxied — kein Directus-Token im Browser nötig
export function assetUrl(fileId /*, _userToken — nicht mehr nötig */) {
if (!fileId) return null
return `${BASE}/languparent/assets/${fileId}`
} }
// ── Profil ────────────────────────────────────────────────────────────────────
export async function getProfilData(userToken) { export async function getProfilData(userToken) {
const res = await fetch( return getMe(userToken)
`${BASE}/users/me?fields=id,username.id,username.username_public,` +
`language_native,language_target,points_total,streak_days`,
{ headers: auth(userToken) }
)
const data = await res.json()
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
return data.data
} }
// Stub — Punkte laufen über addPointsToPair
export async function addPointsToUser() { return true }

View File

@@ -15,13 +15,14 @@ export default function LoginForm({ onNeedsProfile }) {
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return } if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
setError(''); setLoading(true) setError(''); setLoading(true)
try { try {
const { access_token } = await login(email, pw) const result = await login(email, pw)
saveToken(access_token) if (result.needsProfile) {
const me = await getMe(access_token) onNeedsProfile(result.userId, null)
setUser(me) return
if (!me.username || !me.language_native || !me.language_target) {
onNeedsProfile(me.id, access_token)
} }
saveToken(result.access_token)
const me = await getMe(result.access_token)
setUser(me)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {

View File

@@ -1,10 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { registerUser, login, getMe } from '../../api/directus' import { registerUser } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Button, Alert, StepDots } from './ui' import { FormGroup, Input, Button, Alert, StepDots } from './ui'
export default function RegisterStep1({ onSuccess }) { export default function RegisterStep1({ onSuccess }) {
const { saveToken } = useAuth()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -16,11 +14,8 @@ export default function RegisterStep1({ onSuccess }) {
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return } if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
setError(''); setLoading(true) setError(''); setLoading(true)
try { try {
await registerUser(email, pw) const { userId } = await registerUser(email, pw)
const { access_token } = await login(email, pw) onSuccess(userId, null)
saveToken(access_token)
const me = await getMe(access_token)
onSuccess(me.id, access_token)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {

View File

@@ -4,7 +4,7 @@ import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui' import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
export default function RegisterStep2({ userId, userToken, onSuccess }) { export default function RegisterStep2({ userId, userToken, onSuccess }) {
const { setUser } = useAuth() const { setUser, saveToken } = useAuth()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [nativeLang, setNativeLang] = useState('') const [nativeLang, setNativeLang] = useState('')
const [targetLang, setTargetLang] = useState('') const [targetLang, setTargetLang] = useState('')
@@ -35,8 +35,9 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
if (!available) { if (!available) {
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
} }
await createProfile({ userId, username, nativeLang, targetLang, userToken }) const { token } = await createProfile({ userId, username, nativeLang, targetLang })
setUser({ id: userId, username: userId, language_native: nativeLang, language_target: targetLang }) saveToken(token)
setUser({ id: userId, username, language_native: nativeLang, language_target: targetLang })
onSuccess(username) onSuccess(username)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)