Echtes Google-/PayPal-Login: OAuth 2.0 + PKCE, eigene Kundendatenbank, DSGVO-Selbstbedienung
Kundenkonten sind jetzt genauso echt wie die PayPal-Zahlung: server-geprüftes OAuth 2.0 mit PKCE für Google und "Log in with PayPal" (functions/_shared/oauth.js, oauth-handlers.js), neue D1-Tabelle "customers" (bewusst ohne Passwort-Feld), eigene von der Zugangscode-Schranke getrennte Sitzungs-Logik (customer-auth.js). Echte DSGVO-Rechte direkt im Kontobereich: Daten herunterladen (Art. 15/20) und Konto unwiderruflich löschen (Art. 17), Bestellungen bleiben aus gesetzlichen Gründen erhalten. Datenschutzerklärung entsprechend ergänzt. Ohne echte Google-/PayPal-Zugangsdaten zeigt der Login-Button ehrlich einen "noch nicht eingerichtet"-Hinweis statt eine Anmeldung vorzutäuschen. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e80aba5ca6
commit
bf0d169015
@@ -0,0 +1,23 @@
|
|||||||
|
-- Migration 0004: echte Kundenkonten für Google-/PayPal-Login.
|
||||||
|
--
|
||||||
|
-- Bewusst OAuth-only (kein eigenes Passwort-Feld): dadurch entfällt jede Passwort-Sicherheits-
|
||||||
|
-- verantwortung für uns komplett (kein Hashing, kein Passwort-Leck-Risiko) — Google/PayPal
|
||||||
|
-- übernehmen die eigentliche Identitätsprüfung, wir speichern nur, WER (per Provider-ID)
|
||||||
|
-- eingeloggt ist. Siehe functions/_shared/customer-auth.js + functions/api/auth/*.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT NOT NULL,
|
||||||
|
-- "google" oder "paypal" — jeweils per OpenID Connect, siehe functions/_shared/oauth.js.
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('google', 'paypal')),
|
||||||
|
-- Die eindeutige, unveränderliche Nutzer-ID VOM PROVIDER (z.B. Googles "sub"-Feld) — NICHT die
|
||||||
|
-- E-Mail-Adresse, weil E-Mail-Adressen sich ändern können, die Provider-ID aber nicht.
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
|
||||||
@@ -64,3 +64,18 @@ CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_orders_land ON orders(land);
|
CREATE INDEX IF NOT EXISTS idx_orders_land ON orders(land);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
|
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email);
|
CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email);
|
||||||
|
|
||||||
|
-- Echte Kundenkonten (Google-/PayPal-Login, siehe migration-0004-kundenkonten.sql für die
|
||||||
|
-- ausführliche Begründung — bewusst OAuth-only, kein eigenes Passwort-Feld).
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('google', 'paypal')),
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
functions/_shared/customer-auth.js — Sitzungs-Logik für echte Kundenkonten (Google-/PayPal-
|
||||||
|
Login), bewusst GETRENNT von functions/_shared/auth.js (das ist die Zugangscode-Schranke für
|
||||||
|
qciga/VanVan) — unterschiedliche Geheimnisse, unterschiedliche Cookies, unterschiedliche
|
||||||
|
Nutzer:innengruppe. Ein Leck bei der einen Sitzungsart darf nie die andere gefährden.
|
||||||
|
|
||||||
|
Gleiches HMAC-Signaturprinzip wie bei der Zugangscode-Schranke (siehe dort für die
|
||||||
|
Begründung), hier bewusst als eigene, kleine Kopie statt geteiltem Code — weniger Kopplung
|
||||||
|
zwischen zwei sicherheitskritischen, aber konzeptionell getrennten Systemen. ===================================================================== */
|
||||||
|
|
||||||
|
export const CUSTOMER_COOKIE_NAME = "vandiy_customer_session";
|
||||||
|
export const CUSTOMER_SESSION_TAGE = 30;
|
||||||
|
|
||||||
|
function b64urlEncode(bytes) {
|
||||||
|
let bin = "";
|
||||||
|
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||||
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
function b64urlDecodeToBytes(str) {
|
||||||
|
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
while (str.length % 4) str += "=";
|
||||||
|
const bin = atob(str);
|
||||||
|
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
async function hmacKey(secret) {
|
||||||
|
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signiert eine Kunden-Sitzung für die Datenbank-ID des customers-Eintrags. */
|
||||||
|
export async function signCustomerSession(customerId, secret) {
|
||||||
|
const payload = JSON.stringify({ cid: customerId, exp: Date.now() + CUSTOMER_SESSION_TAGE * 24 * 60 * 60 * 1000 });
|
||||||
|
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||||||
|
const key = await hmacKey(secret);
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||||||
|
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCustomerSession(token, secret) {
|
||||||
|
if (!token || !token.includes(".")) return null;
|
||||||
|
const [payloadB64, sigB64] = token.split(".");
|
||||||
|
try {
|
||||||
|
const key = await hmacKey(secret);
|
||||||
|
const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64));
|
||||||
|
if (!valid) return null;
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||||||
|
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||||
|
return payload; // { cid, exp }
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCookie(request, name) {
|
||||||
|
const header = request.headers.get("Cookie") || "";
|
||||||
|
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert den eingeloggten customers-Datensatz (aus D1) für die aktuelle Anfrage, oder null.
|
||||||
|
* Zentrale Stelle, die jede Kunden-API-Route nutzt — Kunden-ID kommt NIE direkt vom Client,
|
||||||
|
* immer nur aus der signierten, serverseitig geprüften Sitzung. */
|
||||||
|
export async function getCustomer(request, env) {
|
||||||
|
if (!env.CUSTOMER_SESSION_SECRET || !env.DB) return null;
|
||||||
|
const token = getCookie(request, CUSTOMER_COOKIE_NAME);
|
||||||
|
const payload = token ? await verifyCustomerSession(token, env.CUSTOMER_SESSION_SECRET) : null;
|
||||||
|
if (!payload?.cid) return null;
|
||||||
|
const row = await env.DB.prepare(
|
||||||
|
`SELECT id, email, name, provider, created_at FROM customers WHERE id = ?`
|
||||||
|
).bind(payload.cid).first();
|
||||||
|
return row || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerCookieHeader(token, maxAgeSekunden) {
|
||||||
|
return `${CUSTOMER_COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSekunden}; HttpOnly; Secure; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCustomerCookieHeader() {
|
||||||
|
return `${CUSTOMER_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
functions/_shared/oauth-handlers.js — gemeinsame Start-/Callback-Logik für alle OAuth-
|
||||||
|
Anbieter (Google, PayPal), von den dünnen Routen unter functions/api/auth/<provider>/start.js
|
||||||
|
bzw. callback.js aufgerufen. So gibt es die sicherheitskritische Logik (State-Prüfung, PKCE,
|
||||||
|
Sitzungserstellung) nur EINMAL, nicht einmal pro Anbieter dupliziert. ===================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
erzeugeZufallswert,
|
||||||
|
codeChallengeFuer,
|
||||||
|
baueAutorisierungsUrl,
|
||||||
|
istProviderKonfiguriert,
|
||||||
|
tauscheCodeGegenToken,
|
||||||
|
holeNutzerprofil,
|
||||||
|
} from "./oauth.js";
|
||||||
|
import { signCustomerSession, customerCookieHeader, CUSTOMER_SESSION_TAGE } from "./customer-auth.js";
|
||||||
|
|
||||||
|
const STATE_COOKIE = "vandiy_oauth_state";
|
||||||
|
const VERIFIER_COOKIE = "vandiy_oauth_verifier";
|
||||||
|
const LANG_COOKIE = "vandiy_oauth_lang";
|
||||||
|
const OAUTH_TEMP_MAXAGE = 600; // 10 Minuten — reicht locker für den Login-Vorgang, hält aber die
|
||||||
|
// Angriffsfläche klein, falls die Cookies je "hängen bleiben".
|
||||||
|
const ERLAUBTE_SPRACHEN = ["", "en", "ch", "fr"]; // "" = Deutsch (kein Präfix)
|
||||||
|
|
||||||
|
function getCookieRaw(request, name) {
|
||||||
|
const header = request.headers.get("Cookie") || "";
|
||||||
|
const m = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function langPrefix(lang) {
|
||||||
|
return ERLAUBTE_SPRACHEN.includes(lang) ? lang : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/auth/:provider/start — leitet zum Anbieter weiter, nachdem State + PKCE-Verifier in
|
||||||
|
* kurzlebigen, signierten Cookies zwischengespeichert wurden. */
|
||||||
|
export async function starteOAuth(context, providerName) {
|
||||||
|
const { request, env } = context;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const lang = langPrefix(url.searchParams.get("lang") || "");
|
||||||
|
|
||||||
|
if (!istProviderKonfiguriert(env, providerName)) {
|
||||||
|
return Response.redirect(`${url.origin}/${lang ? lang + "/" : ""}konto/?login_error=${providerName}_unconfigured`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = erzeugeZufallswert(24);
|
||||||
|
const codeVerifier = erzeugeZufallswert(48);
|
||||||
|
const codeChallenge = await codeChallengeFuer(codeVerifier);
|
||||||
|
const redirectUri = `${url.origin}/api/auth/${providerName}/callback`;
|
||||||
|
const authUrl = baueAutorisierungsUrl(env, providerName, { redirectUri, state, codeChallenge });
|
||||||
|
|
||||||
|
const cookiePath = `/api/auth/${providerName}`;
|
||||||
|
const headers = new Headers({ Location: authUrl });
|
||||||
|
headers.append("Set-Cookie", `${STATE_COOKIE}=${state}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
|
||||||
|
headers.append("Set-Cookie", `${VERIFIER_COOKIE}=${codeVerifier}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
|
||||||
|
headers.append("Set-Cookie", `${LANG_COOKIE}=${lang}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`);
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/auth/:provider/callback — prüft State (CSRF-Schutz), tauscht den Code gegen ein
|
||||||
|
* Zugangstoken, holt das echte Profil direkt vom Anbieter, legt bei Bedarf einen neuen
|
||||||
|
* Kunden-Datensatz an (oder aktualisiert den bestehenden) und setzt die echte Sitzung. */
|
||||||
|
export async function verarbeiteOAuthCallback(context, providerName) {
|
||||||
|
const { request, env } = context;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
const errorParam = url.searchParams.get("error");
|
||||||
|
const lang = langPrefix(getCookieRaw(request, LANG_COOKIE) || "");
|
||||||
|
const kontoPfad = `${url.origin}/${lang ? lang + "/" : ""}konto/`;
|
||||||
|
|
||||||
|
const cookiePath = `/api/auth/${providerName}`;
|
||||||
|
function loescheTempCookies(headers) {
|
||||||
|
headers.append("Set-Cookie", `${STATE_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
|
||||||
|
headers.append("Set-Cookie", `${VERIFIER_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
|
||||||
|
headers.append("Set-Cookie", `${LANG_COOKIE}=; Path=${cookiePath}; Max-Age=0`);
|
||||||
|
}
|
||||||
|
function fehlerRedirect(grund) {
|
||||||
|
const headers = new Headers({ Location: `${kontoPfad}?login_error=${grund}` });
|
||||||
|
loescheTempCookies(headers);
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nutzer:in hat bei Google/PayPal abgebrochen/abgelehnt — kein Fehler, nur kein Login.
|
||||||
|
if (errorParam) return fehlerRedirect(`${providerName}_denied`);
|
||||||
|
if (!code || !state) return fehlerRedirect(`${providerName}_invalid`);
|
||||||
|
|
||||||
|
// ── CSRF-Schutz: der State aus der Weiterleitungs-URL MUSS exakt zum eigenen, kurz zuvor
|
||||||
|
// gesetzten Cookie passen — sonst könnte jemand einen fremden Autorisierungs-Code
|
||||||
|
// "unterschieben" (klassischer OAuth-CSRF-Angriff). ──
|
||||||
|
const stateCookie = getCookieRaw(request, STATE_COOKIE);
|
||||||
|
const verifierCookie = getCookieRaw(request, VERIFIER_COOKIE);
|
||||||
|
if (!stateCookie || stateCookie !== state || !verifierCookie) {
|
||||||
|
return fehlerRedirect(`${providerName}_state_mismatch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.DB || !env.CUSTOMER_SESSION_SECRET) {
|
||||||
|
return fehlerRedirect(`${providerName}_backend_unconfigured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectUri = `${url.origin}/api/auth/${providerName}/callback`;
|
||||||
|
const accessToken = await tauscheCodeGegenToken(env, providerName, { code, redirectUri, codeVerifier: verifierCookie });
|
||||||
|
// Einzige Quelle für die Identität: die direkte, server-zu-server-Antwort vom Anbieter.
|
||||||
|
const profil = await holeNutzerprofil(env, providerName, accessToken);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const bestehend = await env.DB.prepare(
|
||||||
|
`SELECT id FROM customers WHERE provider = ? AND provider_user_id = ?`
|
||||||
|
).bind(providerName, profil.providerUserId).first();
|
||||||
|
|
||||||
|
let customerId;
|
||||||
|
if (bestehend) {
|
||||||
|
customerId = bestehend.id;
|
||||||
|
await env.DB.prepare(`UPDATE customers SET email = ?, name = ?, updated_at = ?, last_login_at = ? WHERE id = ?`)
|
||||||
|
.bind(profil.email, profil.name, now, now, customerId)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
const insert = await env.DB.prepare(
|
||||||
|
`INSERT INTO customers (created_at, updated_at, last_login_at, provider, provider_user_id, email, name) VALUES (?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
.bind(now, now, now, providerName, profil.providerUserId, profil.email, profil.name)
|
||||||
|
.run();
|
||||||
|
customerId = insert.meta.last_row_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = await signCustomerSession(customerId, env.CUSTOMER_SESSION_SECRET);
|
||||||
|
const headers = new Headers({ Location: `${url.origin}/${lang ? lang + "/" : ""}konto/angemeldet/` });
|
||||||
|
headers.append("Set-Cookie", customerCookieHeader(sessionToken, CUSTOMER_SESSION_TAGE * 24 * 60 * 60));
|
||||||
|
loescheTempCookies(headers);
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
} catch (err) {
|
||||||
|
return fehlerRedirect(`${providerName}_failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
functions/_shared/oauth.js — echte OAuth-/OpenID-Connect-Anmeldung für Google und "Log in
|
||||||
|
with PayPal". Standard-Authorization-Code-Flow MIT PKCE (Proof Key for Code Exchange) — auch
|
||||||
|
wenn der Code-Austausch server-seitig läuft (also ein "confidential client" ist, der das
|
||||||
|
Client-Secret kennt), macht PKCE einen abgefangenen Autorisierungs-Code für Angreifer wertlos,
|
||||||
|
ohne den echten, zufällig erzeugten code_verifier — zusätzliche Verteidigungsebene, siehe
|
||||||
|
functions/api/auth/<provider>/start.js für die Erzeugung.
|
||||||
|
|
||||||
|
WICHTIG: Der Server vertraut NIE einer vom Client mitgeschickten Identität — E-Mail/Name/
|
||||||
|
Nutzer-ID kommen ausschließlich aus der direkten, server-zu-server-Antwort von Google/PayPal
|
||||||
|
selbst (holeNutzerprofil()), nachdem WIR den Autorisierungs-Code gegen ein Zugangstoken
|
||||||
|
getauscht haben. Genau dasselbe Sicherheitsprinzip wie bei der PayPal-Zahlungsprüfung (siehe
|
||||||
|
functions/_shared/paypal.js). ===================================================================== */
|
||||||
|
|
||||||
|
function paypalBasis(env) {
|
||||||
|
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||||
|
}
|
||||||
|
function paypalSigninBasis(env) {
|
||||||
|
return env.PAYPAL_ENV === "live" ? "https://www.paypal.com" : "https://www.sandbox.paypal.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS = {
|
||||||
|
google: {
|
||||||
|
authorizeUrl: () => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
tokenUrl: () => "https://oauth2.googleapis.com/token",
|
||||||
|
userinfoUrl: () => "https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
|
scope: "openid email profile",
|
||||||
|
clientIdEnvKey: "GOOGLE_CLIENT_ID",
|
||||||
|
clientSecretEnvKey: "GOOGLE_CLIENT_SECRET",
|
||||||
|
// Google akzeptiert den Token-Exchange als klassisches Formular (application/x-www-form-urlencoded).
|
||||||
|
tokenAuthStyle: "body",
|
||||||
|
parseUserinfo: (data) => ({ providerUserId: data.sub, email: data.email, name: data.name || null }),
|
||||||
|
},
|
||||||
|
paypal: {
|
||||||
|
authorizeUrl: (env) => `${paypalSigninBasis(env)}/signin/authorize`,
|
||||||
|
tokenUrl: (env) => `${paypalBasis(env)}/v1/oauth2/token`,
|
||||||
|
userinfoUrl: (env) => `${paypalBasis(env)}/v1/identity/openidconnect/userinfo/?schema=openid`,
|
||||||
|
scope: "openid email profile",
|
||||||
|
// "Log in with PayPal" nutzt bewusst DIESELBEN App-Zugangsdaten wie die PayPal-Zahlungs-
|
||||||
|
// anbindung (functions/_shared/paypal.js) — VanVan muss in ihrer einen PayPal-App im
|
||||||
|
// Entwickler-Dashboard nur zusätzlich die Funktion "Log in with PayPal" aktivieren, statt
|
||||||
|
// eine zweite App anzulegen (siehe Vault-Doku).
|
||||||
|
clientIdEnvKey: "PAYPAL_CLIENT_ID",
|
||||||
|
clientSecretEnvKey: "PAYPAL_CLIENT_SECRET",
|
||||||
|
tokenAuthStyle: "basic",
|
||||||
|
parseUserinfo: (data) => ({ providerUserId: data.user_id || data.payer_id, email: data.email, name: data.name || null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function provider(name) {
|
||||||
|
const p = PROVIDERS[name];
|
||||||
|
if (!p) throw new Error(`Unbekannter OAuth-Anbieter: ${name}`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istProviderKonfiguriert(env, name) {
|
||||||
|
const p = provider(name);
|
||||||
|
return !!(env[p.clientIdEnvKey] && env[p.clientSecretEnvKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die Weiterleitungs-URL zum Anbieter — state (CSRF-Schutz) und code_challenge (PKCE)
|
||||||
|
* wurden vorher vom Aufrufer erzeugt und in signierten Cookies zwischengespeichert. */
|
||||||
|
export function baueAutorisierungsUrl(env, name, { redirectUri, state, codeChallenge }) {
|
||||||
|
const p = provider(name);
|
||||||
|
const clientId = env[p.clientIdEnvKey];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
response_type: "code",
|
||||||
|
scope: p.scope,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
if (name === "google") params.set("prompt", "select_account");
|
||||||
|
return `${p.authorizeUrl(env)}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tauscht den Autorisierungs-Code server-seitig gegen ein Zugangstoken — das Client-Secret
|
||||||
|
* verlässt den Server dabei nie, es fließt nur in diesen einen serverseitigen Aufruf ein. */
|
||||||
|
export async function tauscheCodeGegenToken(env, name, { code, redirectUri, codeVerifier }) {
|
||||||
|
const p = provider(name);
|
||||||
|
const clientId = env[p.clientIdEnvKey];
|
||||||
|
const clientSecret = env[p.clientSecretEnvKey];
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
||||||
|
if (p.tokenAuthStyle === "basic") {
|
||||||
|
headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
|
||||||
|
} else {
|
||||||
|
body.set("client_id", clientId);
|
||||||
|
body.set("client_secret", clientSecret);
|
||||||
|
}
|
||||||
|
const res = await fetch(p.tokenUrl(env), { method: "POST", headers, body: body.toString() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Token-Austausch bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) throw new Error(`${name} hat kein Zugangstoken geliefert.`);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt Name/E-Mail/Nutzer-ID DIREKT vom Anbieter (server-zu-server, mit dem eben erhaltenen
|
||||||
|
* Zugangstoken) — das ist die einzige Quelle, der wir für die Identität vertrauen. */
|
||||||
|
export async function holeNutzerprofil(env, name, accessToken) {
|
||||||
|
const p = provider(name);
|
||||||
|
const res = await fetch(p.userinfoUrl(env), { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Profilabruf bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const profil = p.parseUserinfo(data);
|
||||||
|
if (!profil.providerUserId || !profil.email) {
|
||||||
|
throw new Error(`${name} hat kein vollständiges Profil geliefert (E-Mail/ID fehlt).`);
|
||||||
|
}
|
||||||
|
return profil;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PKCE-Hilfsfunktionen ──
|
||||||
|
function b64url(bytes) {
|
||||||
|
let bin = "";
|
||||||
|
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||||
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function erzeugeZufallswert(laenge = 32) {
|
||||||
|
const bytes = new Uint8Array(laenge);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return b64url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function codeChallengeFuer(codeVerifier) {
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
||||||
|
return b64url(new Uint8Array(hash));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
POST /api/account/delete — löscht das echte Kundenkonto unwiderruflich (DSGVO Art. 17,
|
||||||
|
"Recht auf Löschung"). Bewusst als echtes Selbstbedienungs-Werkzeug gebaut, nicht nur als
|
||||||
|
"schreib uns eine E-Mail"-Hinweis — genau das, was rechtlich am saubersten ist und was
|
||||||
|
VanVan vor Anfragen/Beschwerden schützt, die sonst manuell bearbeitet werden müssten.
|
||||||
|
|
||||||
|
Löscht NUR den customers-Datensatz (Login-Identität) — bereits aufgegebene Bestellungen
|
||||||
|
(orders-Tabelle) bleiben bestehen, weil dafür eine gesetzliche Aufbewahrungspflicht gilt
|
||||||
|
(handels-/steuerrechtliche Aufbewahrungsfristen, § 257 HGB / § 147 AO) — das Konto ist davon
|
||||||
|
rechtlich unabhängig. ===================================================================== */
|
||||||
|
|
||||||
|
import { json } from "../../_shared/http.js";
|
||||||
|
import { getCustomer, clearCustomerCookieHeader } from "../../_shared/customer-auth.js";
|
||||||
|
|
||||||
|
export async function onRequestPost(context) {
|
||||||
|
const { request, env } = context;
|
||||||
|
const customer = await getCustomer(request, env);
|
||||||
|
if (!customer) return json(401, { ok: false, error: "Nicht angemeldet." });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await env.DB.prepare(`DELETE FROM customers WHERE id = ?`).bind(customer.id).run();
|
||||||
|
} catch {
|
||||||
|
return json(500, { ok: false, error: "Konto konnte nicht gelöscht werden. Bitte versuche es erneut." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers({ "Content-Type": "application/json" });
|
||||||
|
headers.append("Set-Cookie", clearCustomerCookieHeader());
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
GET /api/account/export — liefert alle über dieses Konto gespeicherten Daten als JSON-Datei
|
||||||
|
zum Download (DSGVO Art. 15 "Auskunftsrecht" + Art. 20 "Recht auf Datenübertragbarkeit").
|
||||||
|
Echtes Selbstbedienungs-Werkzeug statt eines manuellen Prozesses. ===================================================================== */
|
||||||
|
|
||||||
|
import { getCustomer } from "../../_shared/customer-auth.js";
|
||||||
|
|
||||||
|
export async function onRequestGet(context) {
|
||||||
|
const { request, env } = context;
|
||||||
|
const customer = await getCustomer(request, env);
|
||||||
|
if (!customer) {
|
||||||
|
return new Response(JSON.stringify({ ok: false, error: "Nicht angemeldet." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const daten = {
|
||||||
|
konto: {
|
||||||
|
email: customer.email,
|
||||||
|
name: customer.name,
|
||||||
|
anmeldeart: customer.provider,
|
||||||
|
erstelltAm: customer.created_at,
|
||||||
|
},
|
||||||
|
hinweis:
|
||||||
|
"Dies sind alle Daten, die Van's DIY & Bastelbedarf zu deinem Kundenkonto speichert. " +
|
||||||
|
"Bereits aufgegebene Bestellungen werden getrennt davon aus gesetzlichen Aufbewahrungsgründen " +
|
||||||
|
"weiter gespeichert (siehe Datenschutzerklärung, Abschnitt Bestellabwicklung).",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(daten, null, 2), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Disposition": 'attachment; filename="meine-daten.json"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
POST /api/account/logout — löscht die echte Kunden-Sitzung (Cookie). Die lokale Demo-
|
||||||
|
Kontoabmeldung (localStorage, siehe scripts/account.ts logout()) läuft weiterhin zusätzlich
|
||||||
|
im Frontend, damit die Kopfzeile sofort reagiert. ===================================================================== */
|
||||||
|
|
||||||
|
import { json } from "../../_shared/http.js";
|
||||||
|
import { clearCustomerCookieHeader } from "../../_shared/customer-auth.js";
|
||||||
|
|
||||||
|
export async function onRequestPost() {
|
||||||
|
const headers = new Headers({ "Content-Type": "application/json" });
|
||||||
|
headers.append("Set-Cookie", clearCustomerCookieHeader());
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
GET /api/account/me — liefert den eingeloggten Kundenkonto-Datensatz (oder ok:false, wenn
|
||||||
|
keine gültige Sitzung vorliegt). Wird vom Frontend (siehe scripts/account.ts-Brücke in
|
||||||
|
konto/index.astro + konto/angemeldet.astro) genutzt, um die echte Server-Sitzung mit dem
|
||||||
|
bisherigen, rein lokalen Demo-Kontosystem abzugleichen — bewusst ok:false statt 401, weil
|
||||||
|
diese Route aktiv zum "unauffällig nachschauen, ob schon eine Sitzung besteht" gedacht ist,
|
||||||
|
nicht zur Absicherung einer geschützten Aktion. ===================================================================== */
|
||||||
|
|
||||||
|
import { json } from "../../_shared/http.js";
|
||||||
|
import { getCustomer } from "../../_shared/customer-auth.js";
|
||||||
|
|
||||||
|
export async function onRequestGet(context) {
|
||||||
|
const { request, env } = context;
|
||||||
|
const customer = await getCustomer(request, env);
|
||||||
|
if (!customer) return json(200, { ok: false });
|
||||||
|
return json(200, {
|
||||||
|
ok: true,
|
||||||
|
customer: { email: customer.email, name: customer.name, provider: customer.provider, seit: customer.created_at },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { verarbeiteOAuthCallback } from "../../../_shared/oauth-handlers.js";
|
||||||
|
|
||||||
|
export function onRequestGet(context) {
|
||||||
|
return verarbeiteOAuthCallback(context, "google");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { starteOAuth } from "../../../_shared/oauth-handlers.js";
|
||||||
|
|
||||||
|
export function onRequestGet(context) {
|
||||||
|
return starteOAuth(context, "google");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { verarbeiteOAuthCallback } from "../../../_shared/oauth-handlers.js";
|
||||||
|
|
||||||
|
export function onRequestGet(context) {
|
||||||
|
return verarbeiteOAuthCallback(context, "paypal");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { starteOAuth } from "../../../_shared/oauth-handlers.js";
|
||||||
|
|
||||||
|
export function onRequestGet(context) {
|
||||||
|
return starteOAuth(context, "paypal");
|
||||||
|
}
|
||||||
@@ -21,24 +21,28 @@
|
|||||||
"body": "Bei Zahlung per PayPal geben wir die für die Zahlungsabwicklung erforderlichen Daten (u. a. Name, Anschrift, Bestellsumme) an die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxembourg, weiter. PayPal verarbeitet diese Daten teilweise auch in den USA über die Muttergesellschaft PayPal, Inc.; PayPal ist nach dem EU-US Data Privacy Framework zertifiziert, das ein angemessenes Datenschutzniveau sicherstellt. Es gelten zusätzlich PayPals eigene Datenschutzhinweise (paypal.com/de/webapps/mpp/ua/privacy-full). Bei Banküberweisung werden Daten an unser kontoführendes Kreditinstitut übermittelt."
|
"body": "Bei Zahlung per PayPal geben wir die für die Zahlungsabwicklung erforderlichen Daten (u. a. Name, Anschrift, Bestellsumme) an die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxembourg, weiter. PayPal verarbeitet diese Daten teilweise auch in den USA über die Muttergesellschaft PayPal, Inc.; PayPal ist nach dem EU-US Data Privacy Framework zertifiziert, das ein angemessenes Datenschutzniveau sicherstellt. Es gelten zusätzlich PayPals eigene Datenschutzhinweise (paypal.com/de/webapps/mpp/ua/privacy-full). Bei Banküberweisung werden Daten an unser kontoführendes Kreditinstitut übermittelt."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "6. Kontaktformular",
|
"heading": "6. Kundenkonto (Anmeldung mit Google oder PayPal)",
|
||||||
|
"body": "Für ein Kundenkonto bieten wir ausschließlich die Anmeldung über bestehende Google- oder PayPal-Konten an (OAuth 2.0 / OpenID Connect) — ein eigenes Passwort bei uns gibt es nicht. Beim ersten Login übermitteln uns Google bzw. PayPal deine E-Mail-Adresse, deinen Namen und eine eindeutige, anbieterseitige Kennung; wir speichern diese Angaben zusammen mit dem Anmeldezeitpunkt in unserer Datenbank (Cloudflare D1), um dich bei künftigen Besuchen wiederzuerkennen. Die Verarbeitung erfolgt auf Grundlage des Art. 6 Abs. 1 lit. b DSGVO (Erfüllung des Nutzungsvertrags für das Kundenkonto) sowie deiner Einwilligung durch den aktiven Klick auf den jeweiligen Anmelde-Button (Art. 6 Abs. 1 lit. a DSGVO). Verantwortlich für die Verarbeitung deiner Daten bei Google ist die Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland; die Muttergesellschaft Google LLC (USA) ist nach unserer Kenntnis nach dem EU-US Data Privacy Framework zertifiziert, das ein angemessenes Datenschutzniveau sicherstellt. Für PayPal gelten dieselben Angaben wie unter Punkt 5 (PayPal (Europe) S.à r.l. et Cie, S.C.A., DPF-Zertifizierung der US-Muttergesellschaft). Es gelten zusätzlich die jeweiligen Datenschutzhinweise von Google (policies.google.com/privacy) bzw. PayPal (paypal.com/de/webapps/mpp/ua/privacy-full). Du kannst dein Kundenkonto jederzeit selbst und ohne Rückfrage in deinem Kontobereich unwiderruflich löschen (Art. 17 DSGVO) oder deine gespeicherten Kontodaten als Datei herunterladen (Art. 15, 20 DSGVO); bereits aufgegebene Bestellungen bleiben davon unberührt, da für sie eigenständige, gesetzliche Aufbewahrungspflichten gelten (siehe Punkt 10)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heading": "7. Kontaktformular",
|
||||||
"body": "Wenn du uns über das Kontaktformular oder per E-Mail Anfragen zukommen lässt, werden deine Angaben aus dem Anfrageformular inklusive der von dir dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Die Verarbeitung dieser Daten erfolgt auf Grundlage deiner Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw., sofern die Anfrage der Anbahnung eines Vertrags dient, auf Grundlage des Art. 6 Abs. 1 lit. b DSGVO. Eine erteilte Einwilligung kannst du jederzeit mit Wirkung für die Zukunft widerrufen, etwa per E-Mail an die oben genannte Adresse."
|
"body": "Wenn du uns über das Kontaktformular oder per E-Mail Anfragen zukommen lässt, werden deine Angaben aus dem Anfrageformular inklusive der von dir dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Die Verarbeitung dieser Daten erfolgt auf Grundlage deiner Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw., sofern die Anfrage der Anbahnung eines Vertrags dient, auf Grundlage des Art. 6 Abs. 1 lit. b DSGVO. Eine erteilte Einwilligung kannst du jederzeit mit Wirkung für die Zukunft widerrufen, etwa per E-Mail an die oben genannte Adresse."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "7. Cookies",
|
"heading": "8. Cookies",
|
||||||
"body": "Diese Website verwendet, soweit technisch erforderlich, Cookies zur Bereitstellung des Warenkorbs. Weitere, nicht zwingend erforderliche Cookies (z. B. Statistik/Marketing) werden nur nach vorheriger Einwilligung gesetzt. [Cookie-Banner/Consent-Tool ergänzen, sobald solche Cookies tatsächlich eingesetzt werden.]"
|
"body": "Diese Website verwendet, soweit technisch erforderlich, Cookies zur Bereitstellung des Warenkorbs sowie — sofern du dich anmeldest — zur Aufrechterhaltung deiner Anmeldesitzung. Weitere, nicht zwingend erforderliche Cookies (z. B. Statistik/Marketing) werden nur nach vorheriger Einwilligung gesetzt. [Cookie-Banner/Consent-Tool ergänzen, sobald solche Cookies tatsächlich eingesetzt werden.]"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "8. Deine Rechte",
|
"heading": "9. Deine Rechte",
|
||||||
"body": "Du hast jederzeit das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) und Widerspruch gegen die Verarbeitung deiner personenbezogenen Daten (Art. 21 DSGVO). Soweit die Verarbeitung auf deiner Einwilligung beruht, kannst du diese jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO), ohne dass die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung berührt wird. Außerdem hast du das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren — insbesondere in dem Mitgliedstaat deines gewöhnlichen Aufenthaltsorts, deines Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes."
|
"body": "Du hast jederzeit das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) und Widerspruch gegen die Verarbeitung deiner personenbezogenen Daten (Art. 21 DSGVO). Soweit die Verarbeitung auf deiner Einwilligung beruht, kannst du diese jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO), ohne dass die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung berührt wird. Außerdem hast du das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren — insbesondere in dem Mitgliedstaat deines gewöhnlichen Aufenthaltsorts, deines Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "9. Automatisierte Entscheidungsfindung",
|
"heading": "10. Automatisierte Entscheidungsfindung",
|
||||||
"body": "Wir setzen keine automatisierte Entscheidungsfindung einschließlich Profiling im Sinne von Art. 22 DSGVO ein."
|
"body": "Wir setzen keine automatisierte Entscheidungsfindung einschließlich Profiling im Sinne von Art. 22 DSGVO ein."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "10. Speicherdauer",
|
"heading": "11. Speicherdauer",
|
||||||
"body": "Wir speichern personenbezogene Daten nur so lange, wie es für den jeweiligen Zweck erforderlich ist oder gesetzliche Aufbewahrungsfristen (insbesondere handels- und steuerrechtlich) dies vorschreiben."
|
"body": "Wir speichern personenbezogene Daten nur so lange, wie es für den jeweiligen Zweck erforderlich ist oder gesetzliche Aufbewahrungsfristen (insbesondere handels- und steuerrechtlich) dies vorschreiben. Dein Kundenkonto (Google-/PayPal-Login) bleibt bestehen, bis du es selbst löschst; bereits aufgegebene Bestellungen werden davon unabhängig aufgrund handels- und steuerrechtlicher Aufbewahrungsfristen (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer weitergespeichert."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-8
@@ -200,14 +200,16 @@ export const ui = {
|
|||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
eyebrow: "Mein Konto", title: "Anmelden oder als Gast bestellen",
|
eyebrow: "Mein Konto", title: "Anmelden oder als Gast bestellen",
|
||||||
todoNote: "Phase 1 – Demo-Anmeldung ohne echte Passwortprüfung: Jede E-Mail-Adresse funktioniert, das Passwort-Feld ist reine Vorschau. Die Bestellungen im Konto sind Beispieldaten. Eine echte Anmeldung mit Datenbank kommt in Phase 2.",
|
todoNote: "Anmeldung mit Google/PayPal ist technisch fertig und echt (eigenes Kundenkonto, sichere Sitzung) — bis Google/PayPal-Zugangsdaten hinterlegt sind, meldet der Button einen klaren Hinweis statt eine Anmeldung vorzutäuschen. Die E-Mail/Passwort-Anmeldung darunter ist weiterhin eine Vorschau ohne echte Passwortprüfung.",
|
||||||
loginTitle: "Anmelden", email: "E-Mail", password: "Passwort", loginButton: "Anmelden", forgotPassword: "Passwort vergessen?",
|
loginTitle: "Anmelden", email: "E-Mail", password: "Passwort", loginButton: "Anmelden", forgotPassword: "Passwort vergessen?",
|
||||||
newTitle: "Neu hier?", newText: "Lege ein Konto an, um Bestellhistorie, Wunschliste und Sendungsverfolgung an einem Ort zu haben – oder bestelle einfach als Gast, ganz ohne Konto.",
|
newTitle: "Neu hier?", newText: "Lege ein Konto an, um Bestellhistorie, Wunschliste und Sendungsverfolgung an einem Ort zu haben – oder bestelle einfach als Gast, ganz ohne Konto.",
|
||||||
guestButton: "Als Gast weiter einkaufen",
|
guestButton: "Als Gast weiter einkaufen",
|
||||||
loginError: "Bitte gib eine E-Mail-Adresse ein.",
|
loginError: "Bitte gib eine E-Mail-Adresse ein.",
|
||||||
socialHeading: "Schneller anmelden mit",
|
socialHeading: "Schneller anmelden mit",
|
||||||
socialGoogle: "Mit Google anmelden", socialPaypal: "Mit PayPal anmelden",
|
socialGoogle: "Mit Google anmelden", socialPaypal: "Mit PayPal anmelden",
|
||||||
socialComingSoon: "Kommt in Phase 2: Sobald die echte Konto-Anbindung steht, kannst du dich hier direkt anmelden — ganz ohne neues Passwort.",
|
loginErrorUnconfigured: "Diese Anmeldeart ist noch nicht eingerichtet. Bitte melde dich per E-Mail an oder versuche es später erneut.",
|
||||||
|
loginErrorDenied: "Anmeldung abgebrochen.",
|
||||||
|
loginErrorGeneric: "Anmeldung fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
orDivider: "oder mit E-Mail",
|
orDivider: "oder mit E-Mail",
|
||||||
showPassword: "Passwort anzeigen", hidePassword: "Passwort verbergen",
|
showPassword: "Passwort anzeigen", hidePassword: "Passwort verbergen",
|
||||||
rememberMe: "Angemeldet bleiben",
|
rememberMe: "Angemeldet bleiben",
|
||||||
@@ -220,6 +222,9 @@ export const ui = {
|
|||||||
previewNote: "Phase 1: Deine Anmeldung, dein Profil und deine Rezensionen werden schon jetzt echt auf diesem Gerät gespeichert. Nur die Bestellungen unten sind Beispieldaten zur Gestaltung — echte Bestellungen kommen in Phase 2 mit Zahlungsanbindung und Datenbank (Cloudflare Worker + D1).",
|
previewNote: "Phase 1: Deine Anmeldung, dein Profil und deine Rezensionen werden schon jetzt echt auf diesem Gerät gespeichert. Nur die Bestellungen unten sind Beispieldaten zur Gestaltung — echte Bestellungen kommen in Phase 2 mit Zahlungsanbindung und Datenbank (Cloudflare Worker + D1).",
|
||||||
notLoggedIn: "Du bist nicht angemeldet — du wirst zur Anmeldung weitergeleitet …",
|
notLoggedIn: "Du bist nicht angemeldet — du wirst zur Anmeldung weitergeleitet …",
|
||||||
logout: "Abmelden",
|
logout: "Abmelden",
|
||||||
|
exportDataButton: "📥 Meine Daten herunterladen", deleteAccountButton: "🗑️ Konto löschen",
|
||||||
|
deleteAccountConfirm: "Dein Konto wird unwiderruflich gelöscht (nicht deine bereits aufgegebenen Bestellungen, die aus gesetzlichen Gründen aufbewahrt werden müssen). Fortfahren?",
|
||||||
|
deleteAccountError: "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
navLabel: "Kontobereich-Navigation",
|
navLabel: "Kontobereich-Navigation",
|
||||||
statOrders: "Bestellungen", statWishlist: "Wunschliste", statOpen: "Offene Sendungen", statData: "Meine Daten", statLoyalty: "Treuebonus",
|
statOrders: "Bestellungen", statWishlist: "Wunschliste", statOpen: "Offene Sendungen", statData: "Meine Daten", statLoyalty: "Treuebonus",
|
||||||
datenHeaderBtn: "👤 Meine Daten",
|
datenHeaderBtn: "👤 Meine Daten",
|
||||||
@@ -502,14 +507,16 @@ export const ui = {
|
|||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
eyebrow: "My Account", title: "Sign in or check out as a guest",
|
eyebrow: "My Account", title: "Sign in or check out as a guest",
|
||||||
todoNote: "Phase 1 – demo sign-in without real password checking: any email address works, the password field is a preview only. Orders shown in the account are example data. A real login with a database is coming in Phase 2.",
|
todoNote: "Sign-in with Google/PayPal is technically finished and real (your own customer account, secure session) — until Google/PayPal credentials are set up, the button shows a clear notice instead of faking a sign-in. The email/password sign-in below remains a preview without real password checking.",
|
||||||
loginTitle: "Sign in", email: "Email", password: "Password", loginButton: "Sign in", forgotPassword: "Forgot password?",
|
loginTitle: "Sign in", email: "Email", password: "Password", loginButton: "Sign in", forgotPassword: "Forgot password?",
|
||||||
newTitle: "New here?", newText: "Create an account to keep order history, wishlist and tracking in one place – or simply check out as a guest, no account needed.",
|
newTitle: "New here?", newText: "Create an account to keep order history, wishlist and tracking in one place – or simply check out as a guest, no account needed.",
|
||||||
guestButton: "Continue shopping as guest",
|
guestButton: "Continue shopping as guest",
|
||||||
loginError: "Please enter an email address.",
|
loginError: "Please enter an email address.",
|
||||||
socialHeading: "Sign in faster with",
|
socialHeading: "Sign in faster with",
|
||||||
socialGoogle: "Sign in with Google", socialPaypal: "Sign in with PayPal",
|
socialGoogle: "Sign in with Google", socialPaypal: "Sign in with PayPal",
|
||||||
socialComingSoon: "Coming in Phase 2: once the real account backend is live, you'll be able to sign in here directly — no new password needed.",
|
loginErrorUnconfigured: "This sign-in method isn't set up yet. Please sign in by email or try again later.",
|
||||||
|
loginErrorDenied: "Sign-in cancelled.",
|
||||||
|
loginErrorGeneric: "Sign-in failed. Please try again.",
|
||||||
orDivider: "or with email",
|
orDivider: "or with email",
|
||||||
showPassword: "Show password", hidePassword: "Hide password",
|
showPassword: "Show password", hidePassword: "Hide password",
|
||||||
rememberMe: "Keep me signed in",
|
rememberMe: "Keep me signed in",
|
||||||
@@ -522,6 +529,9 @@ export const ui = {
|
|||||||
previewNote: "Phase 1: your sign-in, your profile and your reviews are already genuinely saved on this device. Only the orders below are example data for design purposes — real orders arrive in Phase 2 with payment integration and a database (Cloudflare Worker + D1).",
|
previewNote: "Phase 1: your sign-in, your profile and your reviews are already genuinely saved on this device. Only the orders below are example data for design purposes — real orders arrive in Phase 2 with payment integration and a database (Cloudflare Worker + D1).",
|
||||||
notLoggedIn: "You're not signed in — redirecting you to sign in …",
|
notLoggedIn: "You're not signed in — redirecting you to sign in …",
|
||||||
logout: "Sign out",
|
logout: "Sign out",
|
||||||
|
exportDataButton: "📥 Download my data", deleteAccountButton: "🗑️ Delete account",
|
||||||
|
deleteAccountConfirm: "Your account will be deleted permanently (not your past orders, which must be kept for legal reasons). Continue?",
|
||||||
|
deleteAccountError: "Account could not be deleted. Please try again.",
|
||||||
navLabel: "Account section navigation",
|
navLabel: "Account section navigation",
|
||||||
statOrders: "Orders", statWishlist: "Wishlist", statOpen: "Open shipments", statData: "My details", statLoyalty: "Loyalty reward",
|
statOrders: "Orders", statWishlist: "Wishlist", statOpen: "Open shipments", statData: "My details", statLoyalty: "Loyalty reward",
|
||||||
datenHeaderBtn: "👤 My details",
|
datenHeaderBtn: "👤 My details",
|
||||||
@@ -804,14 +814,16 @@ export const ui = {
|
|||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
eyebrow: "Mis Konto", title: "Aamälde oder als Gast bstelle",
|
eyebrow: "Mis Konto", title: "Aamälde oder als Gast bstelle",
|
||||||
todoNote: "Phase 1 – Demo-Aamäldig ohni echti Passwortprüefig: jedi E-Mail-Adrässe funktioniert, s'Passwort-Fäld isch nume Vorschau. D'Bstellige im Konto sind Bispieldate. E echti Aamäldig mit Datebank chunnt i Phase 2.",
|
todoNote: "Aamäldig mit Google/PayPal isch technisch fertig und echt (eigets Kundekonto, sicheri Sitzig) — bis Google/PayPal-Zuegangsdate hinterlegt sind, zeigt dr Knopf en klare Hiwys statt e Aamäldig vorztüsche. D'E-Mail-/Passwort-Aamäldig drunder blibt wyterhin nume Vorschau ohni echti Passwortprüefig.",
|
||||||
loginTitle: "Aamälde", email: "E-Mail", password: "Passwort", loginButton: "Aamälde", forgotPassword: "Passwort vergässe?",
|
loginTitle: "Aamälde", email: "E-Mail", password: "Passwort", loginButton: "Aamälde", forgotPassword: "Passwort vergässe?",
|
||||||
newTitle: "Neu da?", newText: "Leg es Konto a, für Bstellhistorie, Wunschlischte und Sändigsverfolgig a einem Ort z'ha – oder bstell eifach als Gast, ganz ohni Konto.",
|
newTitle: "Neu da?", newText: "Leg es Konto a, für Bstellhistorie, Wunschlischte und Sändigsverfolgig a einem Ort z'ha – oder bstell eifach als Gast, ganz ohni Konto.",
|
||||||
guestButton: "Als Gast wyter yichaufe",
|
guestButton: "Als Gast wyter yichaufe",
|
||||||
loginError: "Bitte gib e E-Mail-Adrässe i.",
|
loginError: "Bitte gib e E-Mail-Adrässe i.",
|
||||||
socialHeading: "Schnäller aamälde mit",
|
socialHeading: "Schnäller aamälde mit",
|
||||||
socialGoogle: "Mit Google aamälde", socialPaypal: "Mit PayPal aamälde",
|
socialGoogle: "Mit Google aamälde", socialPaypal: "Mit PayPal aamälde",
|
||||||
socialComingSoon: "Chunnt i Phase 2: Sobald d'echti Konto-Aabindig staht, chasch di da direkt aamälde — ganz ohni nöis Passwort.",
|
loginErrorUnconfigured: "Die Aamäldeart isch no nid iigrichtet. Bitte mäld di per E-Mail a oder versuech's spöter nochmal.",
|
||||||
|
loginErrorDenied: "Aamäldig abbroche.",
|
||||||
|
loginErrorGeneric: "Aamäldig fählgschlage. Bitte versuech's nochmal.",
|
||||||
orDivider: "oder mit E-Mail",
|
orDivider: "oder mit E-Mail",
|
||||||
showPassword: "Passwort azeige", hidePassword: "Passwort verstecke",
|
showPassword: "Passwort azeige", hidePassword: "Passwort verstecke",
|
||||||
rememberMe: "Aagmäldet bliebe",
|
rememberMe: "Aagmäldet bliebe",
|
||||||
@@ -823,6 +835,9 @@ export const ui = {
|
|||||||
eyebrow: "Mis Konto", greeting: (name: string) => `Hoi ${name} 🩵`, lead: "Schön, bisch wieder da. Da findsch alli dini Bstellige, dini Wunschlischte und dini Date uf ein Blick.",
|
eyebrow: "Mis Konto", greeting: (name: string) => `Hoi ${name} 🩵`, lead: "Schön, bisch wieder da. Da findsch alli dini Bstellige, dini Wunschlischte und dini Date uf ein Blick.",
|
||||||
previewNote: "Phase 1: dini Aamäldig, dis Profil und dini Bewärtige werde scho jetzt echt uf däm Gerät gspeicheret. Nume d'Bstellige unde sind Bispieldate für d'Gstaltig — echti Bstellige chöme i Phase 2 mit Zahligsaabindig und Datebank (Cloudflare Worker + D1).",
|
previewNote: "Phase 1: dini Aamäldig, dis Profil und dini Bewärtige werde scho jetzt echt uf däm Gerät gspeicheret. Nume d'Bstellige unde sind Bispieldate für d'Gstaltig — echti Bstellige chöme i Phase 2 mit Zahligsaabindig und Datebank (Cloudflare Worker + D1).",
|
||||||
notLoggedIn: "Du bisch nid aagmäldet — du wirsch zur Aamäldig wytergleitet …",
|
notLoggedIn: "Du bisch nid aagmäldet — du wirsch zur Aamäldig wytergleitet …",
|
||||||
|
exportDataButton: "📥 Mini Date abelade", deleteAccountButton: "🗑️ Konto lösche",
|
||||||
|
deleteAccountConfirm: "Dis Konto wird unwiderruflich glöscht (nid dini scho ufgäbene Bstellige, wo us gsetzlichne Gründ ufbewahrt werde müesse). Wyterfahre?",
|
||||||
|
deleteAccountError: "Konto het nid chöne glöscht werde. Bitte versuech's nomol.",
|
||||||
logout: "Abmälde",
|
logout: "Abmälde",
|
||||||
navLabel: "Kontobereich-Navigation",
|
navLabel: "Kontobereich-Navigation",
|
||||||
statOrders: "Bstellige", statWishlist: "Wunschlischte", statOpen: "Offni Sändige", statData: "Mini Date", statLoyalty: "Treuebonus",
|
statOrders: "Bstellige", statWishlist: "Wunschlischte", statOpen: "Offni Sändige", statData: "Mini Date", statLoyalty: "Treuebonus",
|
||||||
@@ -1106,14 +1121,16 @@ export const ui = {
|
|||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
eyebrow: "Mon compte", title: "Se connecter ou commander en tant qu'invité",
|
eyebrow: "Mon compte", title: "Se connecter ou commander en tant qu'invité",
|
||||||
todoNote: "Phase 1 – connexion de démonstration sans vérification réelle du mot de passe : n'importe quelle adresse e-mail fonctionne, le champ mot de passe n'est qu'un aperçu. Les commandes affichées dans le compte sont des exemples. Une vraie connexion avec base de données arrivera en phase 2.",
|
todoNote: "La connexion avec Google/PayPal est techniquement terminée et réelle (votre propre compte client, session sécurisée) — tant que les identifiants Google/PayPal ne sont pas configurés, le bouton affiche un message clair plutôt que de simuler une connexion. La connexion par e-mail/mot de passe ci-dessous reste un aperçu sans vérification réelle du mot de passe.",
|
||||||
loginTitle: "Se connecter", email: "E-mail", password: "Mot de passe", loginButton: "Se connecter", forgotPassword: "Mot de passe oublié ?",
|
loginTitle: "Se connecter", email: "E-mail", password: "Mot de passe", loginButton: "Se connecter", forgotPassword: "Mot de passe oublié ?",
|
||||||
newTitle: "Nouveau ici ?", newText: "Créez un compte pour retrouver l'historique des commandes, la liste de souhaits et le suivi au même endroit – ou commandez simplement en tant qu'invité, sans compte.",
|
newTitle: "Nouveau ici ?", newText: "Créez un compte pour retrouver l'historique des commandes, la liste de souhaits et le suivi au même endroit – ou commandez simplement en tant qu'invité, sans compte.",
|
||||||
guestButton: "Continuer mes achats en tant qu'invité",
|
guestButton: "Continuer mes achats en tant qu'invité",
|
||||||
loginError: "Veuillez saisir une adresse e-mail.",
|
loginError: "Veuillez saisir une adresse e-mail.",
|
||||||
socialHeading: "Connexion plus rapide avec",
|
socialHeading: "Connexion plus rapide avec",
|
||||||
socialGoogle: "Se connecter avec Google", socialPaypal: "Se connecter avec PayPal",
|
socialGoogle: "Se connecter avec Google", socialPaypal: "Se connecter avec PayPal",
|
||||||
socialComingSoon: "Disponible en phase 2 : dès que la vraie connexion au compte sera en place, vous pourrez vous connecter directement ici — sans nouveau mot de passe.",
|
loginErrorUnconfigured: "Ce mode de connexion n'est pas encore configuré. Veuillez vous connecter par e-mail ou réessayer plus tard.",
|
||||||
|
loginErrorDenied: "Connexion annulée.",
|
||||||
|
loginErrorGeneric: "Échec de la connexion. Veuillez réessayer.",
|
||||||
orDivider: "ou avec e-mail",
|
orDivider: "ou avec e-mail",
|
||||||
showPassword: "Afficher le mot de passe", hidePassword: "Masquer le mot de passe",
|
showPassword: "Afficher le mot de passe", hidePassword: "Masquer le mot de passe",
|
||||||
rememberMe: "Rester connecté(e)",
|
rememberMe: "Rester connecté(e)",
|
||||||
@@ -1125,6 +1142,9 @@ export const ui = {
|
|||||||
eyebrow: "Mon compte", greeting: (name: string) => `Bonjour ${name} 🩵`, lead: "Ravie de vous revoir. Retrouvez ici toutes vos commandes, votre liste de souhaits et vos données en un coup d'œil.",
|
eyebrow: "Mon compte", greeting: (name: string) => `Bonjour ${name} 🩵`, lead: "Ravie de vous revoir. Retrouvez ici toutes vos commandes, votre liste de souhaits et vos données en un coup d'œil.",
|
||||||
previewNote: "Phase 1 : votre connexion, votre profil et vos avis sont déjà réellement enregistrés sur cet appareil. Seules les commandes ci-dessous sont des exemples destinés à la conception — les vraies commandes arriveront en phase 2 avec le paiement et une base de données (Cloudflare Worker + D1).",
|
previewNote: "Phase 1 : votre connexion, votre profil et vos avis sont déjà réellement enregistrés sur cet appareil. Seules les commandes ci-dessous sont des exemples destinés à la conception — les vraies commandes arriveront en phase 2 avec le paiement et une base de données (Cloudflare Worker + D1).",
|
||||||
notLoggedIn: "Vous n'êtes pas connecté(e) — redirection vers la connexion …",
|
notLoggedIn: "Vous n'êtes pas connecté(e) — redirection vers la connexion …",
|
||||||
|
exportDataButton: "📥 Télécharger mes données", deleteAccountButton: "🗑️ Supprimer le compte",
|
||||||
|
deleteAccountConfirm: "Votre compte sera supprimé définitivement (pas vos commandes déjà passées, qui doivent être conservées pour des raisons légales). Continuer ?",
|
||||||
|
deleteAccountError: "Le compte n'a pas pu être supprimé. Veuillez réessayer.",
|
||||||
logout: "Se déconnecter",
|
logout: "Se déconnecter",
|
||||||
navLabel: "Navigation de l'espace client",
|
navLabel: "Navigation de l'espace client",
|
||||||
statOrders: "Commandes", statWishlist: "Liste de souhaits", statOpen: "Envois en cours", statData: "Mes données", statLoyalty: "Récompense",
|
statOrders: "Commandes", statWishlist: "Liste de souhaits", statOpen: "Envois en cours", statData: "Mes données", statLoyalty: "Récompense",
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20) — nur relevant/sichtbar, wenn
|
||||||
|
eine echte Google-/PayPal-Sitzung aktiv ist (siehe versucheEchteSessionZuUebernehmen()
|
||||||
|
im Skript unten). Bei einer reinen Demo-/Vorschau-Sitzung ohne Server-Konto gibt es
|
||||||
|
nichts zu exportieren/löschen, daher standardmäßig ausgeblendet. */}
|
||||||
|
<div class="profile-dsgvo-actions" id="profile-dsgvo-actions" style="display:none;">
|
||||||
|
<a class="btn btn-outline btn-sm" href="/api/account/export" id="dsgvo-export-btn">{t.accountDash.exportDataButton}</a>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="dsgvo-delete-btn">{t.accountDash.deleteAccountButton}</button>
|
||||||
|
<p class="small" id="dsgvo-delete-error" style="display:none; color: var(--c-sale);">{t.accountDash.deleteAccountError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -528,8 +538,8 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script define:vars={{ loginPath: "/ch/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } } }}>
|
<script define:vars={{ loginPath: "/ch/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } }, deleteAccountConfirm: t.accountDash.deleteAccountConfirm, deleteAccountError: t.accountDash.deleteAccountError }}>
|
||||||
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels };
|
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError };
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
||||||
@@ -616,16 +626,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
import { getAccount, isLoggedIn, login, setName, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
||||||
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
||||||
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
import { aboStufenKonfig } from "../../../data/abo";
|
import { aboStufenKonfig } from "../../../data/abo";
|
||||||
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels } = (window as any).__accountDashVars;
|
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError } = (window as any).__accountDashVars;
|
||||||
|
|
||||||
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
|
// Echte Server-Sitzung (Google-/PayPal-Login, siehe functions/_shared/customer-auth.js)
|
||||||
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
|
// einbinden: falls vorhanden, in das bestehende localStorage-Demo-Kontosystem übernehmen,
|
||||||
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
// damit das unveränderte Dashboard unten korrekt mit echten Daten befüllt wird. Läuft VOR dem
|
||||||
|
// Zugriffsschutz-Check, damit ein frisch per OAuth angemeldeter Besuch nicht fälschlich zurück
|
||||||
|
// zur Login-Seite geschickt wird.
|
||||||
|
let istEchteSitzung = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
istEchteSitzung = true;
|
||||||
|
if (!isLoggedIn()) login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein Backend erreichbar / keine Sitzung — Demo-/Vorschau-Konto (falls vorhanden) läuft normal weiter.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht (echt oder Demo-
|
||||||
|
// Vorschau). Bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
const note = document.getElementById("not-logged-in-note");
|
const note = document.getElementById("not-logged-in-note");
|
||||||
const preview = document.getElementById("preview-note");
|
const preview = document.getElementById("preview-note");
|
||||||
@@ -927,10 +954,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
});
|
});
|
||||||
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
||||||
|
|
||||||
// Logout direkt aus dem Dashboard heraus
|
// Logout direkt aus dem Dashboard heraus — löscht sowohl das lokale Demo-Konto als auch,
|
||||||
|
// falls vorhanden, die echte serverseitige Sitzung (Cookie).
|
||||||
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
||||||
logout();
|
logout();
|
||||||
|
if (istEchteSitzung) {
|
||||||
|
fetch("/api/account/logout", { method: "POST" }).finally(() => { window.location.href = loginPath; });
|
||||||
|
} else {
|
||||||
window.location.href = loginPath;
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20 DSGVO) — nur sichtbar/aktiv bei einer
|
||||||
|
// echten Server-Sitzung, siehe istEchteSitzung oben.
|
||||||
|
const dsgvoBlock = document.getElementById("profile-dsgvo-actions");
|
||||||
|
if (istEchteSitzung && dsgvoBlock) dsgvoBlock.style.display = "flex";
|
||||||
|
document.getElementById("dsgvo-delete-btn")?.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(deleteAccountConfirm)) return;
|
||||||
|
const errorEl = document.getElementById("dsgvo-delete-error");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) throw new Error("delete failed");
|
||||||
|
logout();
|
||||||
|
window.location.href = loginPath;
|
||||||
|
} catch {
|
||||||
|
if (errorEl) errorEl.style.display = "block";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
||||||
|
|||||||
@@ -19,25 +19,24 @@ const t = useTranslations(lang);
|
|||||||
<form class="frm card" id="login-form">
|
<form class="frm card" id="login-form">
|
||||||
<h3>{t.account.loginTitle}</h3>
|
<h3>{t.account.loginTitle}</h3>
|
||||||
|
|
||||||
{/* Social-Login: Google als verbreitetster Anbieter, PayPal zusätzlich wegen seiner
|
{/* Echtes Google-/PayPal-Login (OAuth 2.0 + PKCE, server-seitig geprüft — siehe
|
||||||
Marktdominanz bei Zahlungen in Deutschland (siehe Recherche). Apple bewusst NICHT
|
functions/api/auth/ + functions/_shared/oauth.js). Ganz normale Links, kein JS
|
||||||
dabei -- auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr Apple-Entwicklerprogramm
|
nötig, damit der Login auch ohne JavaScript funktioniert (progressive enhancement).
|
||||||
allein für den Web-Login, siehe Vault-Notiz). Ohne eigenes Backend noch nicht
|
Apple bewusst NICHT dabei — auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr
|
||||||
funktional; ein Klick zeigt ehrlich den Phase-2-Hinweis statt eine Anmeldung
|
Apple-Entwicklerprogramm allein für den Web-Login, siehe Vault-Notiz). */}
|
||||||
vorzutäuschen. */}
|
|
||||||
<div class="social-login">
|
<div class="social-login">
|
||||||
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
||||||
<button type="button" class="btn-social btn-social-google">
|
<a class="btn-social btn-social-google" href="/api/auth/google/start">
|
||||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
||||||
{t.account.socialGoogle}
|
{t.account.socialGoogle}
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="btn-social btn-social-paypal">
|
<a class="btn-social btn-social-paypal" href="/api/auth/paypal/start">
|
||||||
<span class="social-icon-badge" aria-hidden="true">
|
<span class="social-icon-badge" aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
||||||
</span>
|
</span>
|
||||||
{t.account.socialPaypal}
|
{t.account.socialPaypal}
|
||||||
</button>
|
</a>
|
||||||
<p class="small social-note" id="social-note">{t.account.socialComingSoon}</p>
|
<p class="small social-note" id="social-note" style="display:none;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="social-divider">{t.account.orDivider}</div>
|
<div class="social-divider">{t.account.orDivider}</div>
|
||||||
@@ -98,8 +97,13 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script define:vars={{ loginErrorUnconfigured: t.account.loginErrorUnconfigured, loginErrorDenied: t.account.loginErrorDenied, loginErrorGeneric: t.account.loginErrorGeneric, langParam: "ch" }}>
|
||||||
|
window.__loginVars = { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam };
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { login, setName } from "../../../scripts/account";
|
import { login, setName, isLoggedIn } from "../../../scripts/account";
|
||||||
|
const { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam } = (window as any).__loginVars;
|
||||||
|
|
||||||
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
||||||
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
||||||
const error = document.getElementById("login-error");
|
const error = document.getElementById("login-error");
|
||||||
@@ -139,12 +143,37 @@ const t = useTranslations(lang);
|
|||||||
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Social-Login-Buttons: kein echtes OAuth ohne Backend — Klick zeigt stattdessen ehrlich
|
// Echtes Google-/PayPal-Login: Sprache als Query-Parameter mitgeben, damit der Rückweg (siehe
|
||||||
// den Phase-2-Hinweis (siehe .social-note oben im Markup).
|
// functions/_shared/oauth-handlers.js) auf die richtige Sprachversion zurückführt.
|
||||||
const socialNote = document.getElementById("social-note");
|
document.querySelectorAll<HTMLAnchorElement>(".btn-social").forEach((a) => {
|
||||||
document.querySelectorAll(".btn-social").forEach((btn) => {
|
if (langParam) a.href = a.href + "?lang=" + langParam;
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
if (socialNote) socialNote.style.display = "block";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fehler-Rückmeldung vom echten OAuth-Ablauf.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const loginErrorCode = params.get("login_error");
|
||||||
|
if (loginErrorCode) {
|
||||||
|
const note = document.getElementById("social-note");
|
||||||
|
if (note) {
|
||||||
|
let msg = loginErrorGeneric;
|
||||||
|
if (loginErrorCode.includes("unconfigured")) msg = loginErrorUnconfigured;
|
||||||
|
else if (loginErrorCode.includes("denied")) msg = loginErrorDenied;
|
||||||
|
note.textContent = msg;
|
||||||
|
note.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon eine gültige echte Sitzung? Dann direkt zum Dashboard weiterleiten.
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
fetch("/api/account/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
window.location.href = "/ch/konto/angemeldet/";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20) — nur relevant/sichtbar, wenn
|
||||||
|
eine echte Google-/PayPal-Sitzung aktiv ist (siehe versucheEchteSessionZuUebernehmen()
|
||||||
|
im Skript unten). Bei einer reinen Demo-/Vorschau-Sitzung ohne Server-Konto gibt es
|
||||||
|
nichts zu exportieren/löschen, daher standardmäßig ausgeblendet. */}
|
||||||
|
<div class="profile-dsgvo-actions" id="profile-dsgvo-actions" style="display:none;">
|
||||||
|
<a class="btn btn-outline btn-sm" href="/api/account/export" id="dsgvo-export-btn">{t.accountDash.exportDataButton}</a>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="dsgvo-delete-btn">{t.accountDash.deleteAccountButton}</button>
|
||||||
|
<p class="small" id="dsgvo-delete-error" style="display:none; color: var(--c-sale);">{t.accountDash.deleteAccountError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -528,8 +538,8 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script define:vars={{ loginPath: "/en/konto/", fallbackName: "Customer", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } } }}>
|
<script define:vars={{ loginPath: "/en/konto/", fallbackName: "Customer", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } }, deleteAccountConfirm: t.accountDash.deleteAccountConfirm, deleteAccountError: t.accountDash.deleteAccountError }}>
|
||||||
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels };
|
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError };
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
||||||
@@ -616,16 +626,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
import { getAccount, isLoggedIn, login, setName, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
||||||
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
||||||
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
import { aboStufenKonfig } from "../../../data/abo";
|
import { aboStufenKonfig } from "../../../data/abo";
|
||||||
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels } = (window as any).__accountDashVars;
|
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError } = (window as any).__accountDashVars;
|
||||||
|
|
||||||
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
|
// Echte Server-Sitzung (Google-/PayPal-Login, siehe functions/_shared/customer-auth.js)
|
||||||
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
|
// einbinden: falls vorhanden, in das bestehende localStorage-Demo-Kontosystem übernehmen,
|
||||||
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
// damit das unveränderte Dashboard unten korrekt mit echten Daten befüllt wird. Läuft VOR dem
|
||||||
|
// Zugriffsschutz-Check, damit ein frisch per OAuth angemeldeter Besuch nicht fälschlich zurück
|
||||||
|
// zur Login-Seite geschickt wird.
|
||||||
|
let istEchteSitzung = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
istEchteSitzung = true;
|
||||||
|
if (!isLoggedIn()) login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein Backend erreichbar / keine Sitzung — Demo-/Vorschau-Konto (falls vorhanden) läuft normal weiter.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht (echt oder Demo-
|
||||||
|
// Vorschau). Bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
const note = document.getElementById("not-logged-in-note");
|
const note = document.getElementById("not-logged-in-note");
|
||||||
const preview = document.getElementById("preview-note");
|
const preview = document.getElementById("preview-note");
|
||||||
@@ -927,10 +954,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
});
|
});
|
||||||
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
||||||
|
|
||||||
// Logout direkt aus dem Dashboard heraus
|
// Logout direkt aus dem Dashboard heraus — löscht sowohl das lokale Demo-Konto als auch,
|
||||||
|
// falls vorhanden, die echte serverseitige Sitzung (Cookie).
|
||||||
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
||||||
logout();
|
logout();
|
||||||
|
if (istEchteSitzung) {
|
||||||
|
fetch("/api/account/logout", { method: "POST" }).finally(() => { window.location.href = loginPath; });
|
||||||
|
} else {
|
||||||
window.location.href = loginPath;
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20 DSGVO) — nur sichtbar/aktiv bei einer
|
||||||
|
// echten Server-Sitzung, siehe istEchteSitzung oben.
|
||||||
|
const dsgvoBlock = document.getElementById("profile-dsgvo-actions");
|
||||||
|
if (istEchteSitzung && dsgvoBlock) dsgvoBlock.style.display = "flex";
|
||||||
|
document.getElementById("dsgvo-delete-btn")?.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(deleteAccountConfirm)) return;
|
||||||
|
const errorEl = document.getElementById("dsgvo-delete-error");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) throw new Error("delete failed");
|
||||||
|
logout();
|
||||||
|
window.location.href = loginPath;
|
||||||
|
} catch {
|
||||||
|
if (errorEl) errorEl.style.display = "block";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
||||||
|
|||||||
@@ -19,25 +19,21 @@ const t = useTranslations(lang);
|
|||||||
<form class="frm card" id="login-form">
|
<form class="frm card" id="login-form">
|
||||||
<h3>{t.account.loginTitle}</h3>
|
<h3>{t.account.loginTitle}</h3>
|
||||||
|
|
||||||
{/* Social-Login: Google als verbreitetster Anbieter, PayPal zusätzlich wegen seiner
|
{/* Real Google/PayPal login (OAuth 2.0 + PKCE, server-verified — see functions/api/auth/
|
||||||
Marktdominanz bei Zahlungen in Deutschland (siehe Recherche). Apple bewusst NICHT
|
+ functions/_shared/oauth.js). Plain links, no JS needed for the redirect itself. */}
|
||||||
dabei -- auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr Apple-Entwicklerprogramm
|
|
||||||
allein für den Web-Login, siehe Vault-Notiz). Ohne eigenes Backend noch nicht
|
|
||||||
funktional; ein Klick zeigt ehrlich den Phase-2-Hinweis statt eine Anmeldung
|
|
||||||
vorzutäuschen. */}
|
|
||||||
<div class="social-login">
|
<div class="social-login">
|
||||||
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
||||||
<button type="button" class="btn-social btn-social-google">
|
<a class="btn-social btn-social-google" href="/api/auth/google/start">
|
||||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
||||||
{t.account.socialGoogle}
|
{t.account.socialGoogle}
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="btn-social btn-social-paypal">
|
<a class="btn-social btn-social-paypal" href="/api/auth/paypal/start">
|
||||||
<span class="social-icon-badge" aria-hidden="true">
|
<span class="social-icon-badge" aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
||||||
</span>
|
</span>
|
||||||
{t.account.socialPaypal}
|
{t.account.socialPaypal}
|
||||||
</button>
|
</a>
|
||||||
<p class="small social-note" id="social-note">{t.account.socialComingSoon}</p>
|
<p class="small social-note" id="social-note" style="display:none;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="social-divider">{t.account.orDivider}</div>
|
<div class="social-divider">{t.account.orDivider}</div>
|
||||||
@@ -98,8 +94,13 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script define:vars={{ loginErrorUnconfigured: t.account.loginErrorUnconfigured, loginErrorDenied: t.account.loginErrorDenied, loginErrorGeneric: t.account.loginErrorGeneric, langParam: "en" }}>
|
||||||
|
window.__loginVars = { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam };
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { login, setName } from "../../../scripts/account";
|
import { login, setName, isLoggedIn } from "../../../scripts/account";
|
||||||
|
const { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam } = (window as any).__loginVars;
|
||||||
|
|
||||||
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
||||||
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
||||||
const error = document.getElementById("login-error");
|
const error = document.getElementById("login-error");
|
||||||
@@ -139,12 +140,37 @@ const t = useTranslations(lang);
|
|||||||
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Social-Login-Buttons: kein echtes OAuth ohne Backend — Klick zeigt stattdessen ehrlich
|
// Echtes Google-/PayPal-Login: Sprache als Query-Parameter mitgeben, damit der Rückweg (siehe
|
||||||
// den Phase-2-Hinweis (siehe .social-note oben im Markup).
|
// functions/_shared/oauth-handlers.js) auf die richtige Sprachversion zurückführt.
|
||||||
const socialNote = document.getElementById("social-note");
|
document.querySelectorAll<HTMLAnchorElement>(".btn-social").forEach((a) => {
|
||||||
document.querySelectorAll(".btn-social").forEach((btn) => {
|
if (langParam) a.href = a.href + "?lang=" + langParam;
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
if (socialNote) socialNote.style.display = "block";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fehler-Rückmeldung vom echten OAuth-Ablauf.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const loginErrorCode = params.get("login_error");
|
||||||
|
if (loginErrorCode) {
|
||||||
|
const note = document.getElementById("social-note");
|
||||||
|
if (note) {
|
||||||
|
let msg = loginErrorGeneric;
|
||||||
|
if (loginErrorCode.includes("unconfigured")) msg = loginErrorUnconfigured;
|
||||||
|
else if (loginErrorCode.includes("denied")) msg = loginErrorDenied;
|
||||||
|
note.textContent = msg;
|
||||||
|
note.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon eine gültige echte Sitzung? Dann direkt zum Dashboard weiterleiten.
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
fetch("/api/account/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
window.location.href = "/en/konto/angemeldet/";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20) — nur relevant/sichtbar, wenn
|
||||||
|
eine echte Google-/PayPal-Sitzung aktiv ist (siehe versucheEchteSessionZuUebernehmen()
|
||||||
|
im Skript unten). Bei einer reinen Demo-/Vorschau-Sitzung ohne Server-Konto gibt es
|
||||||
|
nichts zu exportieren/löschen, daher standardmäßig ausgeblendet. */}
|
||||||
|
<div class="profile-dsgvo-actions" id="profile-dsgvo-actions" style="display:none;">
|
||||||
|
<a class="btn btn-outline btn-sm" href="/api/account/export" id="dsgvo-export-btn">{t.accountDash.exportDataButton}</a>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="dsgvo-delete-btn">{t.accountDash.deleteAccountButton}</button>
|
||||||
|
<p class="small" id="dsgvo-delete-error" style="display:none; color: var(--c-sale);">{t.accountDash.deleteAccountError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -528,8 +538,8 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script define:vars={{ loginPath: "/fr/konto/", fallbackName: "Cliente", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } } }}>
|
<script define:vars={{ loginPath: "/fr/konto/", fallbackName: "Cliente", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } }, deleteAccountConfirm: t.accountDash.deleteAccountConfirm, deleteAccountError: t.accountDash.deleteAccountError }}>
|
||||||
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels };
|
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError };
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
||||||
@@ -616,16 +626,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
import { getAccount, isLoggedIn, login, setName, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../../scripts/account";
|
||||||
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
import { rechnungAlsPdfHerunterladen } from "../../../scripts/invoice-pdf";
|
||||||
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
import { fuegeTeddyGratisHinzu } from "../../../scripts/cart";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
import { aboStufenKonfig } from "../../../data/abo";
|
import { aboStufenKonfig } from "../../../data/abo";
|
||||||
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels } = (window as any).__accountDashVars;
|
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError } = (window as any).__accountDashVars;
|
||||||
|
|
||||||
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
|
// Echte Server-Sitzung (Google-/PayPal-Login, siehe functions/_shared/customer-auth.js)
|
||||||
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
|
// einbinden: falls vorhanden, in das bestehende localStorage-Demo-Kontosystem übernehmen,
|
||||||
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
// damit das unveränderte Dashboard unten korrekt mit echten Daten befüllt wird. Läuft VOR dem
|
||||||
|
// Zugriffsschutz-Check, damit ein frisch per OAuth angemeldeter Besuch nicht fälschlich zurück
|
||||||
|
// zur Login-Seite geschickt wird.
|
||||||
|
let istEchteSitzung = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
istEchteSitzung = true;
|
||||||
|
if (!isLoggedIn()) login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein Backend erreichbar / keine Sitzung — Demo-/Vorschau-Konto (falls vorhanden) läuft normal weiter.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht (echt oder Demo-
|
||||||
|
// Vorschau). Bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
const note = document.getElementById("not-logged-in-note");
|
const note = document.getElementById("not-logged-in-note");
|
||||||
const preview = document.getElementById("preview-note");
|
const preview = document.getElementById("preview-note");
|
||||||
@@ -927,10 +954,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
});
|
});
|
||||||
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
||||||
|
|
||||||
// Logout direkt aus dem Dashboard heraus
|
// Logout direkt aus dem Dashboard heraus — löscht sowohl das lokale Demo-Konto als auch,
|
||||||
|
// falls vorhanden, die echte serverseitige Sitzung (Cookie).
|
||||||
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
||||||
logout();
|
logout();
|
||||||
|
if (istEchteSitzung) {
|
||||||
|
fetch("/api/account/logout", { method: "POST" }).finally(() => { window.location.href = loginPath; });
|
||||||
|
} else {
|
||||||
window.location.href = loginPath;
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20 DSGVO) — nur sichtbar/aktiv bei einer
|
||||||
|
// echten Server-Sitzung, siehe istEchteSitzung oben.
|
||||||
|
const dsgvoBlock = document.getElementById("profile-dsgvo-actions");
|
||||||
|
if (istEchteSitzung && dsgvoBlock) dsgvoBlock.style.display = "flex";
|
||||||
|
document.getElementById("dsgvo-delete-btn")?.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(deleteAccountConfirm)) return;
|
||||||
|
const errorEl = document.getElementById("dsgvo-delete-error");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) throw new Error("delete failed");
|
||||||
|
logout();
|
||||||
|
window.location.href = loginPath;
|
||||||
|
} catch {
|
||||||
|
if (errorEl) errorEl.style.display = "block";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
||||||
|
|||||||
@@ -19,25 +19,24 @@ const t = useTranslations(lang);
|
|||||||
<form class="frm card" id="login-form">
|
<form class="frm card" id="login-form">
|
||||||
<h3>{t.account.loginTitle}</h3>
|
<h3>{t.account.loginTitle}</h3>
|
||||||
|
|
||||||
{/* Social-Login: Google als verbreitetster Anbieter, PayPal zusätzlich wegen seiner
|
{/* Echtes Google-/PayPal-Login (OAuth 2.0 + PKCE, server-seitig geprüft — siehe
|
||||||
Marktdominanz bei Zahlungen in Deutschland (siehe Recherche). Apple bewusst NICHT
|
functions/api/auth/ + functions/_shared/oauth.js). Ganz normale Links, kein JS
|
||||||
dabei -- auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr Apple-Entwicklerprogramm
|
nötig, damit der Login auch ohne JavaScript funktioniert (progressive enhancement).
|
||||||
allein für den Web-Login, siehe Vault-Notiz). Ohne eigenes Backend noch nicht
|
Apple bewusst NICHT dabei — auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr
|
||||||
funktional; ein Klick zeigt ehrlich den Phase-2-Hinweis statt eine Anmeldung
|
Apple-Entwicklerprogramm allein für den Web-Login, siehe Vault-Notiz). */}
|
||||||
vorzutäuschen. */}
|
|
||||||
<div class="social-login">
|
<div class="social-login">
|
||||||
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
||||||
<button type="button" class="btn-social btn-social-google">
|
<a class="btn-social btn-social-google" href="/api/auth/google/start">
|
||||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
||||||
{t.account.socialGoogle}
|
{t.account.socialGoogle}
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="btn-social btn-social-paypal">
|
<a class="btn-social btn-social-paypal" href="/api/auth/paypal/start">
|
||||||
<span class="social-icon-badge" aria-hidden="true">
|
<span class="social-icon-badge" aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
||||||
</span>
|
</span>
|
||||||
{t.account.socialPaypal}
|
{t.account.socialPaypal}
|
||||||
</button>
|
</a>
|
||||||
<p class="small social-note" id="social-note">{t.account.socialComingSoon}</p>
|
<p class="small social-note" id="social-note" style="display:none;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="social-divider">{t.account.orDivider}</div>
|
<div class="social-divider">{t.account.orDivider}</div>
|
||||||
@@ -98,8 +97,13 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script define:vars={{ loginErrorUnconfigured: t.account.loginErrorUnconfigured, loginErrorDenied: t.account.loginErrorDenied, loginErrorGeneric: t.account.loginErrorGeneric, langParam: "fr" }}>
|
||||||
|
window.__loginVars = { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam };
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { login, setName } from "../../../scripts/account";
|
import { login, setName, isLoggedIn } from "../../../scripts/account";
|
||||||
|
const { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam } = (window as any).__loginVars;
|
||||||
|
|
||||||
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
||||||
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
||||||
const error = document.getElementById("login-error");
|
const error = document.getElementById("login-error");
|
||||||
@@ -139,12 +143,37 @@ const t = useTranslations(lang);
|
|||||||
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Social-Login-Buttons: kein echtes OAuth ohne Backend — Klick zeigt stattdessen ehrlich
|
// Echtes Google-/PayPal-Login: Sprache als Query-Parameter mitgeben, damit der Rückweg (siehe
|
||||||
// den Phase-2-Hinweis (siehe .social-note oben im Markup).
|
// functions/_shared/oauth-handlers.js) auf die richtige Sprachversion zurückführt.
|
||||||
const socialNote = document.getElementById("social-note");
|
document.querySelectorAll<HTMLAnchorElement>(".btn-social").forEach((a) => {
|
||||||
document.querySelectorAll(".btn-social").forEach((btn) => {
|
if (langParam) a.href = a.href + "?lang=" + langParam;
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
if (socialNote) socialNote.style.display = "block";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fehler-Rückmeldung vom echten OAuth-Ablauf.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const loginErrorCode = params.get("login_error");
|
||||||
|
if (loginErrorCode) {
|
||||||
|
const note = document.getElementById("social-note");
|
||||||
|
if (note) {
|
||||||
|
let msg = loginErrorGeneric;
|
||||||
|
if (loginErrorCode.includes("unconfigured")) msg = loginErrorUnconfigured;
|
||||||
|
else if (loginErrorCode.includes("denied")) msg = loginErrorDenied;
|
||||||
|
note.textContent = msg;
|
||||||
|
note.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon eine gültige echte Sitzung? Dann direkt zum Dashboard weiterleiten.
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
fetch("/api/account/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
window.location.href = "/fr/konto/angemeldet/";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -316,6 +316,16 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20) — nur relevant/sichtbar, wenn
|
||||||
|
eine echte Google-/PayPal-Sitzung aktiv ist (siehe versucheEchteSessionZuUebernehmen()
|
||||||
|
im Skript unten). Bei einer reinen Demo-/Vorschau-Sitzung ohne Server-Konto gibt es
|
||||||
|
nichts zu exportieren/löschen, daher standardmäßig ausgeblendet. */}
|
||||||
|
<div class="profile-dsgvo-actions" id="profile-dsgvo-actions" style="display:none;">
|
||||||
|
<a class="btn btn-outline btn-sm" href="/api/account/export" id="dsgvo-export-btn">{t.accountDash.exportDataButton}</a>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" id="dsgvo-delete-btn">{t.accountDash.deleteAccountButton}</button>
|
||||||
|
<p class="small" id="dsgvo-delete-error" style="display:none; color: var(--c-sale);">{t.accountDash.deleteAccountError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -537,8 +547,8 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script define:vars={{ loginPath: "/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } } }}>
|
<script define:vars={{ loginPath: "/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge , invoiceLang: lang, invoiceLabels: { title: t.accountDash.invoiceTitle, numberLabel: t.accountDash.invoiceNumberLabel, dateLabel: t.accountDash.invoiceDateLabel, sellerLabel: t.accountDash.invoiceSellerLabel, billToLabel: t.accountDash.invoiceBillToLabel, itemLabel: t.accountDash.invoiceItemLabel, qtyLabel: t.accountDash.invoiceQtyLabel, unitPriceLabel: t.accountDash.invoiceUnitPriceLabel, sumLabel: t.accountDash.invoiceSumLabel, totalLabel: t.accountDash.orderTotal, vatNote: t.common.vatNote }, loyaltyProgressTemplate: t.accountDash.loyaltyProgressTemplate, loyaltyRemainingOne: t.accountDash.loyaltyRemainingOne, loyaltyRemainingOther: t.accountDash.loyaltyRemainingOther, loyaltyUnlockedTitle: t.accountDash.loyaltyUnlockedTitle, loyaltyUnlockedText: t.accountDash.loyaltyUnlockedText, loyaltyStatRedeemedNever: t.accountDash.loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix: t.accountDash.loyaltyStatRedeemedSuffix, loyaltyLastRedeemed: t.accountDash.loyaltyLastRedeemed, aboLang: lang, aboLabels: { nameKlein: t.accountDash.aboNameKlein, nameMittel: t.accountDash.aboNameMittel, nameGross: t.accountDash.aboNameGross, preisSuffix: t.accountDash.aboPreisSuffix, monateSuffix: t.accountDash.aboMonateSuffix, fortschrittKleinTemplate: t.accountDash.aboFortschrittKleinTemplate, fortschrittGrossTemplate: t.accountDash.aboFortschrittGrossTemplate, praemienErhaltenTemplate: t.accountDash.aboPraemienErhaltenTemplate, aktivBadge: t.accountDash.aboAktivesLabel, nichtAktivBadge: t.accountDash.aboNichtAktivBadge, gekuendigtHinweisTemplate: t.accountDash.aboGekuendigtHinweisTemplate, kuendigenConfirm: t.accountDash.aboKuendigenConfirm, abschliessenBtn: t.accountDash.aboAbschliessenBtn, wechselnBtn: t.accountDash.aboWechselnBtn, nichtBuchbar: t.accountDash.aboNichtBuchbar, keineBenachrichtigungen: t.accountDash.aboKeineBenachrichtigungen, notifKeyLabels: { abo_abgeschlossen: t.accountDash.notifAboAbgeschlossen, abo_gewechselt: t.accountDash.notifAboGewechselt, abo_zahlung_erfolgreich: t.accountDash.notifAboZahlungErfolgreich, abo_teddy_klein_frei: t.accountDash.notifAboTeddyKleinFrei, abo_teddy_gross_frei: t.accountDash.notifAboTeddyGrossFrei, abo_bald_praemie: t.accountDash.notifAboBaldPraemie, abo_gekuendigt: t.accountDash.notifAboGekuendigt, abo_teddy_klein_eingeloest: t.accountDash.notifAboTeddyKleinEingeloest, abo_teddy_gross_eingeloest: t.accountDash.notifAboTeddyGrossEingeloest } }, deleteAccountConfirm: t.accountDash.deleteAccountConfirm, deleteAccountError: t.accountDash.deleteAccountError }}>
|
||||||
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels };
|
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError };
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
// Schnellnavigation als echte Filter-Tabs: Klick zeigt NUR den passenden Bereich, blendet den
|
||||||
@@ -629,16 +639,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
setActiveTab(gueltigeHashTabs.includes(hashTab) ? hashTab : null);
|
setActiveTab(gueltigeHashTabs.includes(hashTab) ? hashTab : null);
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../scripts/account";
|
import { getAccount, isLoggedIn, login, setName, logout, setUsername, setPhoto, hatBestellungBewertet, anzeigeName, treueFortschritt, aboStatus, aboAbschliessen, aboZahlungSimulieren, aboKuendigen, teddyEinloesen } from "../../scripts/account";
|
||||||
import { rechnungAlsPdfHerunterladen } from "../../scripts/invoice-pdf";
|
import { rechnungAlsPdfHerunterladen } from "../../scripts/invoice-pdf";
|
||||||
import { fuegeTeddyGratisHinzu } from "../../scripts/cart";
|
import { fuegeTeddyGratisHinzu } from "../../scripts/cart";
|
||||||
import { formatPrice } from "../../i18n/format";
|
import { formatPrice } from "../../i18n/format";
|
||||||
import { aboStufenKonfig } from "../../data/abo";
|
import { aboStufenKonfig } from "../../data/abo";
|
||||||
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels } = (window as any).__accountDashVars;
|
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText, invoiceLang, invoiceLabels, loyaltyProgressTemplate, loyaltyRemainingOne, loyaltyRemainingOther, loyaltyUnlockedTitle, loyaltyUnlockedText, loyaltyStatRedeemedNever, loyaltyStatRedeemedSuffix, loyaltyLastRedeemed, aboLang, aboLabels, deleteAccountConfirm, deleteAccountError } = (window as any).__accountDashVars;
|
||||||
|
|
||||||
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
|
// Echte Server-Sitzung (Google-/PayPal-Login, siehe functions/_shared/customer-auth.js)
|
||||||
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
|
// einbinden: falls vorhanden, in das bestehende localStorage-Demo-Kontosystem übernehmen,
|
||||||
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
// damit das unveränderte Dashboard unten korrekt mit echten Daten befüllt wird. Läuft VOR dem
|
||||||
|
// Zugriffsschutz-Check, damit ein frisch per OAuth angemeldeter Besuch nicht fälschlich zurück
|
||||||
|
// zur Login-Seite geschickt wird.
|
||||||
|
let istEchteSitzung = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
istEchteSitzung = true;
|
||||||
|
if (!isLoggedIn()) login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein Backend erreichbar / keine Sitzung — Demo-/Vorschau-Konto (falls vorhanden) läuft normal weiter.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht (echt oder Demo-
|
||||||
|
// Vorschau). Bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
const note = document.getElementById("not-logged-in-note");
|
const note = document.getElementById("not-logged-in-note");
|
||||||
const preview = document.getElementById("preview-note");
|
const preview = document.getElementById("preview-note");
|
||||||
@@ -943,10 +970,33 @@ const angekommeneBestellungen = beispielBestellungen.filter((b) => b.status ===
|
|||||||
});
|
});
|
||||||
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
|
||||||
|
|
||||||
// Logout direkt aus dem Dashboard heraus
|
// Logout direkt aus dem Dashboard heraus — löscht sowohl das lokale Demo-Konto als auch,
|
||||||
|
// falls vorhanden, die echte serverseitige Sitzung (Cookie).
|
||||||
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
|
||||||
logout();
|
logout();
|
||||||
|
if (istEchteSitzung) {
|
||||||
|
fetch("/api/account/logout", { method: "POST" }).finally(() => { window.location.href = loginPath; });
|
||||||
|
} else {
|
||||||
window.location.href = loginPath;
|
window.location.href = loginPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Echte DSGVO-Selbstbedienungs-Rechte (Art. 15/17/20 DSGVO) — nur sichtbar/aktiv bei einer
|
||||||
|
// echten Server-Sitzung, siehe istEchteSitzung oben.
|
||||||
|
const dsgvoBlock = document.getElementById("profile-dsgvo-actions");
|
||||||
|
if (istEchteSitzung && dsgvoBlock) dsgvoBlock.style.display = "flex";
|
||||||
|
document.getElementById("dsgvo-delete-btn")?.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(deleteAccountConfirm)) return;
|
||||||
|
const errorEl = document.getElementById("dsgvo-delete-error");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.ok) throw new Error("delete failed");
|
||||||
|
logout();
|
||||||
|
window.location.href = loginPath;
|
||||||
|
} catch {
|
||||||
|
if (errorEl) errorEl.style.display = "block";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
// Rechnung direkt auf der Seite ansehen: natives <dialog> pro Bestellung, geöffnet/geschlossen
|
||||||
|
|||||||
+52
-19
@@ -19,25 +19,24 @@ const t = useTranslations(lang);
|
|||||||
<form class="frm card" id="login-form">
|
<form class="frm card" id="login-form">
|
||||||
<h3>{t.account.loginTitle}</h3>
|
<h3>{t.account.loginTitle}</h3>
|
||||||
|
|
||||||
{/* Social-Login: Google als verbreitetster Anbieter, PayPal zusätzlich wegen seiner
|
{/* Echtes Google-/PayPal-Login (OAuth 2.0 + PKCE, server-seitig geprüft — siehe
|
||||||
Marktdominanz bei Zahlungen in Deutschland (siehe Recherche). Apple bewusst NICHT
|
functions/api/auth/ + functions/_shared/oauth.js). Ganz normale Links, kein JS
|
||||||
dabei -- auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr Apple-Entwicklerprogramm
|
nötig, damit der Login auch ohne JavaScript funktioniert (progressive enhancement).
|
||||||
allein für den Web-Login, siehe Vault-Notiz). Ohne eigenes Backend noch nicht
|
Apple bewusst NICHT dabei — auf ausdrücklichen Wunsch entfernt (kostet 99 $/Jahr
|
||||||
funktional; ein Klick zeigt ehrlich den Phase-2-Hinweis statt eine Anmeldung
|
Apple-Entwicklerprogramm allein für den Web-Login, siehe Vault-Notiz). */}
|
||||||
vorzutäuschen. */}
|
|
||||||
<div class="social-login">
|
<div class="social-login">
|
||||||
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
<p class="small" style="margin:0 0 -0.2rem;">{t.account.socialHeading}</p>
|
||||||
<button type="button" class="btn-social btn-social-google">
|
<a class="btn-social btn-social-google" href="/api/auth/google/start">
|
||||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
||||||
{t.account.socialGoogle}
|
{t.account.socialGoogle}
|
||||||
</button>
|
</a>
|
||||||
<button type="button" class="btn-social btn-social-paypal">
|
<a class="btn-social btn-social-paypal" href="/api/auth/paypal/start">
|
||||||
<span class="social-icon-badge" aria-hidden="true">
|
<span class="social-icon-badge" aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
|
||||||
</span>
|
</span>
|
||||||
{t.account.socialPaypal}
|
{t.account.socialPaypal}
|
||||||
</button>
|
</a>
|
||||||
<p class="small social-note" id="social-note">{t.account.socialComingSoon}</p>
|
<p class="small social-note" id="social-note" style="display:none;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="social-divider">{t.account.orDivider}</div>
|
<div class="social-divider">{t.account.orDivider}</div>
|
||||||
@@ -98,8 +97,13 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script define:vars={{ loginErrorUnconfigured: t.account.loginErrorUnconfigured, loginErrorDenied: t.account.loginErrorDenied, loginErrorGeneric: t.account.loginErrorGeneric, langParam: "" }}>
|
||||||
|
window.__loginVars = { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam };
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import { login, setName } from "../../scripts/account";
|
import { login, setName, isLoggedIn } from "../../scripts/account";
|
||||||
|
const { loginErrorUnconfigured, loginErrorDenied, loginErrorGeneric, langParam } = (window as any).__loginVars;
|
||||||
|
|
||||||
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
const form = document.getElementById("login-form") as HTMLFormElement | null;
|
||||||
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
|
||||||
const error = document.getElementById("login-error");
|
const error = document.getElementById("login-error");
|
||||||
@@ -139,12 +143,41 @@ const t = useTranslations(lang);
|
|||||||
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
if (icon) icon.innerHTML = showing ? EYE_OPEN : EYE_CLOSED;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Social-Login-Buttons: kein echtes OAuth ohne Backend — Klick zeigt stattdessen ehrlich
|
// Echtes Google-/PayPal-Login: Sprache als Query-Parameter mitgeben, damit der Rückweg (siehe
|
||||||
// den Phase-2-Hinweis (siehe .social-note oben im Markup).
|
// functions/_shared/oauth-handlers.js) auf die richtige Sprachversion von /konto/angemeldet/
|
||||||
const socialNote = document.getElementById("social-note");
|
// zurückführt.
|
||||||
document.querySelectorAll(".btn-social").forEach((btn) => {
|
document.querySelectorAll<HTMLAnchorElement>(".btn-social").forEach((a) => {
|
||||||
btn.addEventListener("click", () => {
|
if (langParam) a.href = a.href + "?lang=" + langParam;
|
||||||
if (socialNote) socialNote.style.display = "block";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fehler-Rückmeldung vom echten OAuth-Ablauf (siehe functions/_shared/oauth-handlers.js) —
|
||||||
|
// kommt als ?login_error=... in der URL zurück, wenn z.B. noch keine Google-/PayPal-
|
||||||
|
// Zugangsdaten hinterlegt sind oder die Anmeldung abgebrochen wurde.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const loginErrorCode = params.get("login_error");
|
||||||
|
if (loginErrorCode) {
|
||||||
|
const note = document.getElementById("social-note");
|
||||||
|
if (note) {
|
||||||
|
let msg = loginErrorGeneric;
|
||||||
|
if (loginErrorCode.includes("unconfigured")) msg = loginErrorUnconfigured;
|
||||||
|
else if (loginErrorCode.includes("denied")) msg = loginErrorDenied;
|
||||||
|
note.textContent = msg;
|
||||||
|
note.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon eine gültige echte Sitzung? Dann direkt zum Dashboard weiterleiten, statt das
|
||||||
|
// Login-Formular nochmal anzuzeigen.
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
fetch("/api/account/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.ok && data.customer) {
|
||||||
|
login(data.customer.email);
|
||||||
|
if (data.customer.name) setName(data.customer.name);
|
||||||
|
window.location.href = "/konto/angemeldet/";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1862,6 +1862,11 @@ a:focus-visible, button:focus-visible {
|
|||||||
.profile-field-row { display: flex; gap: 0.6rem; }
|
.profile-field-row { display: flex; gap: 0.6rem; }
|
||||||
.profile-field-row input { flex: 1; }
|
.profile-field-row input { flex: 1; }
|
||||||
|
|
||||||
|
/* DSGVO-Selbstbedienung (Art. 15/17/20) — nur bei echter Server-Sitzung eingeblendet, siehe
|
||||||
|
istEchteSitzung in angemeldet.astro. */
|
||||||
|
.profile-dsgvo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.7rem; margin-top: 1.2rem; padding-top: 1.2rem; border-top: 1px solid rgba(255, 255, 255, 0.12); }
|
||||||
|
.profile-dsgvo-actions #dsgvo-delete-btn { color: var(--c-sale); border-color: var(--c-sale); }
|
||||||
|
|
||||||
/* Kundenrezensionen — Herzen in Lila-Babyblau statt Sterne (siehe HeartRating.astro), damit
|
/* Kundenrezensionen — Herzen in Lila-Babyblau statt Sterne (siehe HeartRating.astro), damit
|
||||||
Bewertungen sich erkennbar in die restliche Seite einfügen statt wie ein fremdes Bewertungs-
|
Bewertungen sich erkennbar in die restliche Seite einfügen statt wie ein fremdes Bewertungs-
|
||||||
Widget zu wirken. */
|
Widget zu wirken. */
|
||||||
|
|||||||
Reference in New Issue
Block a user