Passwort ändern
- Wähle ein neues Passwort (mindestens 4 Zeichen, wie beim Login gewohnt empfehlen wir längere Passwörter).
+ Das aktuelle Passwort wird geprüft. Danach sind andere Sitzungen ungültig. Mindestens 8 Zeichen.
+ ) : null}
+
+ {!challengeToken ? (
+ ) : null}
- {mode === 'login' && (
+ {mode === 'login' && !challengeToken && (
setPreview(null)}
onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 0b1774a..7e35847 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -25,6 +25,39 @@ export async function login(email, password) {
})
}
+export async function loginTotp(challengeToken, code) {
+ return request('/api/auth/login/totp', {
+ method: 'POST',
+ body: JSON.stringify({ challenge_token: challengeToken, code }),
+ })
+}
+
+export async function getTotpStatus() {
+ return request('/api/auth/totp/status')
+}
+
+export async function setupTotp() {
+ return request('/api/auth/totp/setup', { method: 'POST', body: '{}' })
+}
+
+export async function confirmTotp(code) {
+ return request('/api/auth/totp/confirm', {
+ method: 'POST',
+ body: JSON.stringify({ code }),
+ })
+}
+
+export async function disableTotp(currentPassword, code) {
+ return request('/api/auth/totp/disable', {
+ method: 'POST',
+ body: JSON.stringify({ current_password: currentPassword, code }),
+ })
+}
+
+export async function issueMediaGrant() {
+ return request('/api/auth/media-grant', { method: 'POST', body: '{}' })
+}
+
export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', {
method: 'POST',
@@ -126,10 +159,13 @@ export async function managementPasswordReset(profileId, newPassword = null) {
})
}
-export async function changePassword(newPassword) {
+export async function changePassword(currentPassword, newPassword) {
return request('/api/auth/pin', {
method: 'PUT',
- body: JSON.stringify({ pin: newPassword }),
+ body: JSON.stringify({
+ current_password: currentPassword,
+ new_password: newPassword,
+ }),
})
}
@@ -992,6 +1028,12 @@ export async function healthCheck() {
export const api = {
// Auth
login,
+ loginTotp,
+ getTotpStatus,
+ setupTotp,
+ confirmTotp,
+ disableTotp,
+ issueMediaGrant,
register,
logout,
getCurrentProfile,
diff --git a/frontend/src/utils/exerciseMediaUrl.js b/frontend/src/utils/exerciseMediaUrl.js
index 9ca4026..48d71f2 100644
--- a/frontend/src/utils/exerciseMediaUrl.js
+++ b/frontend/src/utils/exerciseMediaUrl.js
@@ -1,31 +1,77 @@
-/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
+/** URL für Übungs-Mediendateien. Query-Token ist ein kurzlebiges Medien-Grant, kein Sitzungstoken. */
+
+import { useEffect, useState } from 'react'
+import api from './api'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
+let grant = { token: '', expiresAt: 0 }
+const listeners = new Set()
+
+function notify() {
+ listeners.forEach((fn) => fn())
+}
+
+export function peekMediaAccessToken() {
+ if (grant.expiresAt > Date.now() + 15_000) return grant.token
+ return ''
+}
+
+export function clearMediaGrant() {
+ grant = { token: '', expiresAt: 0 }
+ notify()
+}
+
+export async function refreshMediaGrant() {
+ const data = await api.issueMediaGrant()
+ const expiresAt = data?.expires_at ? new Date(data.expires_at).getTime() : Date.now() + 9 * 60 * 1000
+ grant = { token: data?.token || '', expiresAt }
+ notify()
+ return grant.token
+}
+
+export function useMediaAccessToken() {
+ const [token, setToken] = useState(peekMediaAccessToken)
+ useEffect(() => {
+ const pull = () => setToken(peekMediaAccessToken())
+ listeners.add(pull)
+ pull()
+ if (!peekMediaAccessToken()) {
+ refreshMediaGrant().catch(() => {})
+ }
+ return () => listeners.delete(pull)
+ }, [])
+ return token
+}
+
+function withGrant(path, accessToken) {
+ const token = accessToken || peekMediaAccessToken()
+ const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
+ return `${API_BASE}${path}${q}`
+}
+
/**
* @param {number|string} exerciseId
* @param {object} media — exercise_media Zeile mit id, file_path
+ * @param {string} [accessToken]
* @returns {string|null}
*/
-export function resolveExerciseMediaFileUrl(exerciseId, media) {
+export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) {
if (!media?.file_path) return null
const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
- const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
- const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id
if (id == null || exerciseId == null) return null
- return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
+ return withGrant(`/api/exercises/${exerciseId}/media/${id}/file`, accessToken)
}
/**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId — media_assets.id
+ * @param {string} [accessToken]
* @returns {string|null}
*/
-export function resolveMediaAssetFileUrl(assetId) {
+export function resolveMediaAssetFileUrl(assetId, accessToken) {
if (assetId == null) return null
- const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
- const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
- return `${API_BASE}/api/media-assets/${assetId}/file${q}`
+ return withGrant(`/api/media-assets/${assetId}/file`, accessToken)
}