Add OAuth integration for multiple providers and implement user linking
Some checks failed
Deploy to production / deploy (push) Failing after 49s
Some checks failed
Deploy to production / deploy (push) Failing after 49s
- Created OAuth credentials setup guide for Google, Microsoft, Keycloak, ORY, and ZITADEL. - Added migration for oauth_identity table to store OAuth identities linked to users. - Implemented OAuthIdentity model for managing OAuth identities in the database. - Developed oauthService to handle OAuth login, user creation, and identity linking. - Created OAuthCallbackView and OAuthUserCallbackView components for handling OAuth responses in the frontend. - Added error handling and user feedback during the OAuth process.
This commit is contained in:
259
OAUTH_CREDENTIALS_SETUP.md
Normal file
259
OAUTH_CREDENTIALS_SETUP.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# OAuth Credentials Setup Guide
|
||||||
|
|
||||||
|
Anleitung zum Sammeln der OAuth-Credentials für alle 5 Provider.
|
||||||
|
|
||||||
|
## Redirect URIs
|
||||||
|
Für alle Provider benötigst du folgende Redirect URIs (ersetze `www.your-part.de` mit deiner echten Domain):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.your-part.de/auth/oauth/callback
|
||||||
|
https://www.your-part.de/auth/oauth/user/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Lokal zum Testen:
|
||||||
|
```
|
||||||
|
http://localhost:3000/auth/oauth/callback
|
||||||
|
http://localhost:3000/auth/oauth/user/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Google
|
||||||
|
|
||||||
|
### Credentials besorgen:
|
||||||
|
|
||||||
|
1. Öffne [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Erstelle ein neues Projekt oder wähle ein bestehendes
|
||||||
|
3. Navigiere zu **APIs & Services** → **Credentials**
|
||||||
|
4. Klick **+ CREATE CREDENTIALS** → **OAuth 2.0 Client IDs**
|
||||||
|
5. Wähle **Web application**
|
||||||
|
6. Füge unter **Authorized redirect URIs** hinzu:
|
||||||
|
- `https://www.your-part.de/auth/oauth/callback`
|
||||||
|
- `https://www.your-part.de/auth/oauth/user/callback`
|
||||||
|
7. Speichern und die **Client ID** und **Client Secret** kopieren
|
||||||
|
|
||||||
|
### .env:
|
||||||
|
```env
|
||||||
|
OAUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Microsoft Azure
|
||||||
|
|
||||||
|
### Credentials besorgen:
|
||||||
|
|
||||||
|
1. Öffne [Azure Portal](https://portal.azure.com/)
|
||||||
|
2. Navigiere zu **Azure Active Directory** → **App registrations** → **+ New registration**
|
||||||
|
3. Gib einen Namen ein (z.B. "YourPart OAuth")
|
||||||
|
4. Wähle **Accounts in any organizational directory (Any Azure AD directory - Multitenant)**
|
||||||
|
5. Bei **Redirect URI** wähle **Web** und füge ein:
|
||||||
|
- `https://www.your-part.de/auth/oauth/callback`
|
||||||
|
6. Klick **Register**
|
||||||
|
7. Notiere die **Application (client) ID**
|
||||||
|
8. Gehe zu **Certificates & secrets** → **+ New client secret**
|
||||||
|
9. Erstelle ein Secret und kopiere den **Value** (nicht die ID!)
|
||||||
|
10. Gehe zu **Token configuration** und stelle sicher, dass die richtigen Claims enthalten sind
|
||||||
|
|
||||||
|
### Zusätzliche URI hinzufügen:
|
||||||
|
1. Gehe zu **Authentication**
|
||||||
|
2. Unter **Redirect URIs** klick **+ Add URI**
|
||||||
|
3. Füge hinzu: `https://www.your-part.de/auth/oauth/user/callback`
|
||||||
|
|
||||||
|
### .env:
|
||||||
|
```env
|
||||||
|
OAUTH_MICROSOFT_CLIENT_ID=your-application-id
|
||||||
|
OAUTH_MICROSOFT_CLIENT_SECRET=your-client-secret-value
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Keycloak
|
||||||
|
|
||||||
|
Keycloak ist ein Open-Source OIDC Provider. Du kannst ihn selbst hosten oder eine gehostete Lösung nutzen.
|
||||||
|
|
||||||
|
### Option A: Selbst gehostet mit Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e KEYCLOAK_ADMIN=admin \
|
||||||
|
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||||
|
quay.io/keycloak/keycloak:latest \
|
||||||
|
start-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann:
|
||||||
|
1. Öffne http://localhost:8080
|
||||||
|
2. Login mit `admin` / `admin`
|
||||||
|
3. **Realm erstellen**: Oben links Dropdown → **Create realm**
|
||||||
|
- Name: `yourpart` (oder beliebig)
|
||||||
|
4. Im neuen Realm: **Clients** → **Create client**
|
||||||
|
- Client ID: `yourpart`
|
||||||
|
- Client Protocol: `openid-connect`
|
||||||
|
- Access Type: `confidential`
|
||||||
|
5. Im **Settings** Tab:
|
||||||
|
- Valid Redirect URIs:
|
||||||
|
```
|
||||||
|
https://www.your-part.de/auth/oauth/callback
|
||||||
|
https://www.your-part.de/auth/oauth/user/callback
|
||||||
|
```
|
||||||
|
6. Speichern
|
||||||
|
7. Gehe zu **Credentials** Tab
|
||||||
|
- **Client Secret** kopieren
|
||||||
|
|
||||||
|
### Option B: Gehosteter Service
|
||||||
|
- [Keycloak.cloud](https://www.keycloak.cloud/) oder
|
||||||
|
- [Red Hat Managed Keycloak](https://www.keycloak.org/cloud/)
|
||||||
|
|
||||||
|
### .env:
|
||||||
|
```env
|
||||||
|
OAUTH_KEYCLOAK_ISSUER=https://your-keycloak-domain/realms/yourpart
|
||||||
|
OAUTH_KEYCLOAK_CLIENT_ID=yourpart
|
||||||
|
OAUTH_KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ORY Hydra / ORY Cloud
|
||||||
|
|
||||||
|
ORY ist ein moderner OIDC Provider. Am einfachsten ist ORY Cloud.
|
||||||
|
|
||||||
|
### Option A: ORY Cloud (empfohlen)
|
||||||
|
|
||||||
|
1. Öffne [ORY Cloud Console](https://console.ory.sh/)
|
||||||
|
2. Registriere dich oder logge dich ein
|
||||||
|
3. Erstelle ein neues **Project**
|
||||||
|
4. Gehe zu **Applications**
|
||||||
|
5. Klick **Create New Application**
|
||||||
|
6. Gib einen Namen ein (z.B. "YourPart")
|
||||||
|
7. Unter **Redirect URLs** füge ein:
|
||||||
|
```
|
||||||
|
https://www.your-part.de/auth/oauth/callback
|
||||||
|
https://www.your-part.de/auth/oauth/user/callback
|
||||||
|
```
|
||||||
|
8. Speichern
|
||||||
|
9. Die **Client ID** und **Client Secret** werden angezeigt
|
||||||
|
10. Finde deine **Issuer URL** in den Project Settings (meist `https://your-project-slug.eu.hydra.cloud`)
|
||||||
|
|
||||||
|
### Option B: Selbst gehostet (komplex)
|
||||||
|
|
||||||
|
Siehe [ORY Hydra Dokumentation](https://www.ory.sh/hydra/docs/)
|
||||||
|
|
||||||
|
### .env:
|
||||||
|
```env
|
||||||
|
OAUTH_ORY_ISSUER=https://your-project-slug.eu.hydra.cloud
|
||||||
|
OAUTH_ORY_CLIENT_ID=your-client-id
|
||||||
|
OAUTH_ORY_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ZITADEL
|
||||||
|
|
||||||
|
ZITADEL ist ein Zero-Trust Identity Platform als SaaS.
|
||||||
|
|
||||||
|
### Credentials besorgen:
|
||||||
|
|
||||||
|
1. Öffne [ZITADEL Console](https://zitadel.cloud/)
|
||||||
|
2. Registriere dich oder logge dich ein
|
||||||
|
3. Erstelle eine neue **Organization** (oder verwende die existierende)
|
||||||
|
4. Gehe zu **Projects** → **+ New Project**
|
||||||
|
5. Gib einen Namen ein (z.B. "YourPart")
|
||||||
|
6. Gehe zum Projekt → **Applications** → **+ New Application**
|
||||||
|
7. Wähle **Type: Web**
|
||||||
|
8. Gib einen Namen ein
|
||||||
|
9. Bei **Redirect URIs** füge ein:
|
||||||
|
```
|
||||||
|
https://www.your-part.de/auth/oauth/callback
|
||||||
|
https://www.your-part.de/auth/oauth/user/callback
|
||||||
|
```
|
||||||
|
10. Speichern
|
||||||
|
11. Unter **Client Information** kopiere:
|
||||||
|
- **Client ID**
|
||||||
|
- **Client Secret** (falls sichtbar, sonst im "CREDENTIALS" Tab generieren)
|
||||||
|
12. Finde deine **Issuer URL** in den Organization Settings (meist `https://your-instance.zitadel.cloud`)
|
||||||
|
|
||||||
|
### .env:
|
||||||
|
```env
|
||||||
|
OAUTH_ZITADEL_ISSUER=https://your-instance.zitadel.cloud
|
||||||
|
OAUTH_ZITADEL_CLIENT_ID=your-client-id
|
||||||
|
OAUTH_ZITADEL_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komplette .env für alle 5 Provider
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Google
|
||||||
|
OAUTH_GOOGLE_CLIENT_ID=...
|
||||||
|
OAUTH_GOOGLE_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# Microsoft
|
||||||
|
OAUTH_MICROSOFT_CLIENT_ID=...
|
||||||
|
OAUTH_MICROSOFT_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
OAUTH_KEYCLOAK_ISSUER=...
|
||||||
|
OAUTH_KEYCLOAK_CLIENT_ID=...
|
||||||
|
OAUTH_KEYCLOAK_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# ORY
|
||||||
|
OAUTH_ORY_ISSUER=...
|
||||||
|
OAUTH_ORY_CLIENT_ID=...
|
||||||
|
OAUTH_ORY_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# ZITADEL
|
||||||
|
OAUTH_ZITADEL_ISSUER=...
|
||||||
|
OAUTH_ZITADEL_CLIENT_ID=...
|
||||||
|
OAUTH_ZITADEL_CLIENT_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnell-Checkliste
|
||||||
|
|
||||||
|
- [ ] Google: Client ID & Secret
|
||||||
|
- [ ] Microsoft: Application ID & Client Secret
|
||||||
|
- [ ] Keycloak: Issuer, Client ID, Secret
|
||||||
|
- [ ] ORY: Issuer, Client ID, Secret
|
||||||
|
- [ ] ZITADEL: Issuer, Client ID, Secret
|
||||||
|
- [ ] Alle Redirect URIs in den Providern konfiguriert
|
||||||
|
- [ ] .env datei aktualisiert
|
||||||
|
- [ ] Server neu gestartet (`npm restart`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Nach der Konfiguration:
|
||||||
|
|
||||||
|
1. Frontend öffnen: http://www.your-part.de
|
||||||
|
2. Auf "Login" oder Provider-Button klicken
|
||||||
|
3. Jeder verfügbare Provider sollte als Button angezeigt werden
|
||||||
|
4. Test: Mit jedem Provider einloggen
|
||||||
|
5. Test: Existender Nutzer → Einstellungen → Authentifizierung hinzufügen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid redirect URI"
|
||||||
|
- Stelle sicher, dass die Redirect URIs **exakt** übereinstimmen (inkl. `https://` vs `http://`)
|
||||||
|
- Beachte Trailing Slashes
|
||||||
|
|
||||||
|
### "Invalid client secret"
|
||||||
|
- Kopiere das Secret neu (nicht die ID)
|
||||||
|
- Manche Provider verstecken das Secret nach einmaliger Anzeige
|
||||||
|
|
||||||
|
### "Discovery endpoint not found"
|
||||||
|
- Überprüfe die Issuer URL (mit/ohne Trailing Slash)
|
||||||
|
- Für Keycloak: URL muss auf `/realms/xxx` enden
|
||||||
|
- Für ORY: URL darf nicht auf `/` enden
|
||||||
|
|
||||||
|
### Port-Konflikt lokal
|
||||||
|
- Keycloak benutzt `8080` → ändere auf: `docker run -p 8081:8080 ...`
|
||||||
|
- Stelle sicher, dass 3000 (Frontend) und 5000 (Backend) frei sind
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as userService from '../services/authService.js';
|
import * as userService from '../services/authService.js';
|
||||||
|
import * as oauthService from '../services/oauthService.js';
|
||||||
|
|
||||||
class AuthController {
|
class AuthController {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -7,6 +8,13 @@ class AuthController {
|
|||||||
this.forgotPassword = this.forgotPassword.bind(this);
|
this.forgotPassword = this.forgotPassword.bind(this);
|
||||||
this.activateAccount = this.activateAccount.bind(this);
|
this.activateAccount = this.activateAccount.bind(this);
|
||||||
this.logout = this.logout.bind(this);
|
this.logout = this.logout.bind(this);
|
||||||
|
this.oauthProviders = this.oauthProviders.bind(this);
|
||||||
|
this.oauthStart = this.oauthStart.bind(this);
|
||||||
|
this.oauthExchange = this.oauthExchange.bind(this);
|
||||||
|
this.oauthUserIdentities = this.oauthUserIdentities.bind(this);
|
||||||
|
this.oauthUserStart = this.oauthUserStart.bind(this);
|
||||||
|
this.oauthUserExchange = this.oauthUserExchange.bind(this);
|
||||||
|
this.oauthUserRemove = this.oauthUserRemove.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(req, res) {
|
async register(req, res) {
|
||||||
@@ -43,6 +51,133 @@ class AuthController {
|
|||||||
res.status(200).json({ result: 'loggedout' });
|
res.status(200).json({ result: 'loggedout' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async oauthProviders(req, res) {
|
||||||
|
try {
|
||||||
|
const providers = await oauthService.getOAuthProviders();
|
||||||
|
res.status(200).json({ providers });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthStart(req, res) {
|
||||||
|
const { provider } = req.params;
|
||||||
|
try {
|
||||||
|
const redirectTo = await oauthService.startOAuthLogin({ providerSlug: provider });
|
||||||
|
res.redirect(302, redirectTo.toString());
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'providernotconfigured' ? 503 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthExchange(req, res) {
|
||||||
|
const { code, state } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await oauthService.exchangeOAuthLogin({ code, state });
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const knownErrors = new Set([
|
||||||
|
'oauthcodemissing',
|
||||||
|
'oauthstatemissing',
|
||||||
|
'oauthsubjectmissing',
|
||||||
|
'providernotconfigured',
|
||||||
|
'oauthidentityconflict'
|
||||||
|
]);
|
||||||
|
const status = knownErrors.has(error.message) ? 400 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthUserIdentities(req, res) {
|
||||||
|
const { userid: hashedUserId } = req.headers;
|
||||||
|
try {
|
||||||
|
const User = (await import('../models/community/user.js')).default;
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'usernotfound' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const identities = await oauthService.getUserOAuthIdentities(user.id);
|
||||||
|
res.status(200).json({ identities });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthUserStart(req, res) {
|
||||||
|
const { userid: hashedUserId } = req.headers;
|
||||||
|
const { provider } = req.params;
|
||||||
|
try {
|
||||||
|
const User = (await import('../models/community/user.js')).default;
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'usernotfound' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectTo = await oauthService.startOAuthLoginForUser({
|
||||||
|
userId: user.id,
|
||||||
|
providerSlug: provider
|
||||||
|
});
|
||||||
|
res.redirect(302, redirectTo.toString());
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'providernotconfigured' ? 503 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthUserExchange(req, res) {
|
||||||
|
const { userid: hashedUserId } = req.headers;
|
||||||
|
const { code, state } = req.body;
|
||||||
|
try {
|
||||||
|
const User = (await import('../models/community/user.js')).default;
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'usernotfound' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await oauthService.exchangeOAuthLoginForUser({
|
||||||
|
userId: user.id,
|
||||||
|
code,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const knownErrors = new Set([
|
||||||
|
'oauthcodemissing',
|
||||||
|
'oauthstatemissing',
|
||||||
|
'oauthsubjectmissing',
|
||||||
|
'providernotconfigured',
|
||||||
|
'oauthuseridmismatch',
|
||||||
|
'oauthidentityalreadylinked'
|
||||||
|
]);
|
||||||
|
const status = knownErrors.has(error.message) ? 400 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async oauthUserRemove(req, res) {
|
||||||
|
const { userid: hashedUserId } = req.headers;
|
||||||
|
const { identityId } = req.params;
|
||||||
|
try {
|
||||||
|
const User = (await import('../models/community/user.js')).default;
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'usernotfound' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await oauthService.removeOAuthIdentity({
|
||||||
|
userId: user.id,
|
||||||
|
identityId: parseInt(identityId, 10)
|
||||||
|
});
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'forbidden' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async forgotPassword(req, res) {
|
async forgotPassword(req, res) {
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,3 +19,20 @@ DB_PASS=
|
|||||||
#
|
#
|
||||||
# Optional (Defaults siehe utils/sequelize.js)
|
# Optional (Defaults siehe utils/sequelize.js)
|
||||||
# DB_CONNECT_TIMEOUT_MS=30000
|
# DB_CONNECT_TIMEOUT_MS=30000
|
||||||
|
|
||||||
|
# OAuth / OpenID Connect
|
||||||
|
# FRONTEND_URL muss auf die öffentliche Frontend-URL zeigen, damit der Provider nach dem Login
|
||||||
|
# korrekt auf den Callback im SPA zurückspringen kann.
|
||||||
|
# OAUTH_GOOGLE_CLIENT_ID=
|
||||||
|
# OAUTH_GOOGLE_CLIENT_SECRET=
|
||||||
|
# OAUTH_MICROSOFT_CLIENT_ID=
|
||||||
|
# OAUTH_MICROSOFT_CLIENT_SECRET=
|
||||||
|
# OAUTH_KEYCLOAK_ISSUER=
|
||||||
|
# OAUTH_KEYCLOAK_CLIENT_ID=
|
||||||
|
# OAUTH_KEYCLOAK_CLIENT_SECRET=
|
||||||
|
# OAUTH_ORY_ISSUER=
|
||||||
|
# OAUTH_ORY_CLIENT_ID=
|
||||||
|
# OAUTH_ORY_CLIENT_SECRET=
|
||||||
|
# OAUTH_ZITADEL_ISSUER=
|
||||||
|
# OAUTH_ZITADEL_CLIENT_ID=
|
||||||
|
# OAUTH_ZITADEL_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
{ schema: 'community', tableName: 'oauth_identity' },
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: { schema: 'community', tableName: 'user' },
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: Sequelize.STRING(64),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.addIndex(
|
||||||
|
{ schema: 'community', tableName: 'oauth_identity' },
|
||||||
|
['provider', 'subject'],
|
||||||
|
{ unique: true, name: 'oauth_identity_provider_subject_uniq' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.addIndex(
|
||||||
|
{ schema: 'community', tableName: 'oauth_identity' },
|
||||||
|
['user_id'],
|
||||||
|
{ name: 'oauth_identity_user_idx' }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable({ schema: 'community', tableName: 'oauth_identity' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import Room from './chat/room.js';
|
|||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
import UserDashboard from './community/user_dashboard.js';
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
|
import OAuthIdentity from './community/oauth_identity.js';
|
||||||
import UserParamType from './type/user_param.js';
|
import UserParamType from './type/user_param.js';
|
||||||
import UserRightType from './type/user_right.js';
|
import UserRightType from './type/user_right.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
@@ -182,6 +183,9 @@ export default function setupAssociations() {
|
|||||||
User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' });
|
User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' });
|
||||||
UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
|
User.hasMany(OAuthIdentity, { foreignKey: 'userId', as: 'oauthIdentities' });
|
||||||
|
OAuthIdentity.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
||||||
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
||||||
|
|
||||||
|
|||||||
54
backend/models/community/oauth_identity.js
Normal file
54
backend/models/community/oauth_identity.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
const OAuthIdentity = sequelize.define('oauth_identity', {
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'user_id'
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: DataTypes.STRING(64),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'created_at',
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'updated_at',
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'oauth_identity',
|
||||||
|
schema: 'community',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['provider', 'subject']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default OAuthIdentity;
|
||||||
@@ -8,6 +8,7 @@ import WidgetType from './type/widget_type.js';
|
|||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
import UserDashboard from './community/user_dashboard.js';
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
|
import OAuthIdentity from './community/oauth_identity.js';
|
||||||
import Login from './logs/login.js';
|
import Login from './logs/login.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
import InterestType from './type/interest.js';
|
import InterestType from './type/interest.js';
|
||||||
@@ -168,6 +169,7 @@ const models = {
|
|||||||
User,
|
User,
|
||||||
UserParam,
|
UserParam,
|
||||||
UserDashboard,
|
UserDashboard,
|
||||||
|
OAuthIdentity,
|
||||||
Login,
|
Login,
|
||||||
UserRight,
|
UserRight,
|
||||||
InterestType,
|
InterestType,
|
||||||
|
|||||||
6712
backend/package-lock.json
generated
6712
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
|||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"nodemailer": "^8.0.3",
|
"nodemailer": "^8.0.3",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"redis": "^5.11.0",
|
"redis": "^5.11.0",
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ router.post('/register', authController.register);
|
|||||||
router.post('/login', authController.login);
|
router.post('/login', authController.login);
|
||||||
router.post('/forgot-password', authController.forgotPassword);
|
router.post('/forgot-password', authController.forgotPassword);
|
||||||
router.post('/activate', authController.activateAccount);
|
router.post('/activate', authController.activateAccount);
|
||||||
|
router.get('/oauth/providers', authController.oauthProviders);
|
||||||
|
router.get('/oauth/:provider/start', authController.oauthStart);
|
||||||
|
router.post('/oauth/exchange', authController.oauthExchange);
|
||||||
|
|
||||||
// Geschützte Routen (Authentifizierung erforderlich)
|
// Geschützte Routen (Authentifizierung erforderlich)
|
||||||
router.get('/logout', authController.logout);
|
router.get('/logout', authController.logout);
|
||||||
|
router.get('/oauth/user/identities', authController.oauthUserIdentities);
|
||||||
|
router.get('/oauth/user/:provider/start', authController.oauthUserStart);
|
||||||
|
router.post('/oauth/user/exchange', authController.oauthUserExchange);
|
||||||
|
router.delete('/oauth/user/:identityId', authController.oauthUserRemove);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
578
backend/services/oauthService.js
Normal file
578
backend/services/oauthService.js
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import * as oidc from 'openid-client';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
import UserParamType from '../models/type/user_param.js';
|
||||||
|
import UserParamValue from '../models/type/user_param_value.js';
|
||||||
|
import OAuthIdentity from '../models/community/oauth_identity.js';
|
||||||
|
import Friendship from '../models/community/friendship.js';
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import { redisClient } from '../utils/redis.js';
|
||||||
|
import { setUserSession, deleteUserSession } from '../utils/redis.js';
|
||||||
|
import { notifyUser } from '../utils/socket.js';
|
||||||
|
import { encrypt } from '../utils/encryption.js';
|
||||||
|
|
||||||
|
const saltRounds = 10;
|
||||||
|
const OAUTH_STATE_TTL_SECONDS = 15 * 60;
|
||||||
|
const OAUTH_CALLBACK_PATH = '/auth/oauth/callback';
|
||||||
|
|
||||||
|
const STATIC_PROVIDER_DEFS = [
|
||||||
|
{
|
||||||
|
slug: 'google',
|
||||||
|
label: 'Google',
|
||||||
|
issuer: 'https://accounts.google.com',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'microsoft',
|
||||||
|
label: 'Microsoft',
|
||||||
|
issuer: 'https://login.microsoftonline.com/common/v2.0',
|
||||||
|
scope: 'openid email profile offline_access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'keycloak',
|
||||||
|
label: 'Keycloak',
|
||||||
|
issuer: process.env.OAUTH_KEYCLOAK_ISSUER || '',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'ory',
|
||||||
|
label: 'ORY',
|
||||||
|
issuer: process.env.OAUTH_ORY_ISSUER || '',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'zitadel',
|
||||||
|
label: 'ZITADEL',
|
||||||
|
issuer: process.env.OAUTH_ZITADEL_ISSUER || '',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerCache = new Map();
|
||||||
|
|
||||||
|
const buildEncryptedEmailCandidates = (email) => {
|
||||||
|
const encrypted = encrypt(email);
|
||||||
|
return [
|
||||||
|
Buffer.from(encrypted, 'utf8'),
|
||||||
|
Buffer.from(encrypted, 'hex')
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFriends = async (userId) => {
|
||||||
|
const friendships = await Friendship.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ user1Id: userId },
|
||||||
|
{ user2Id: userId },
|
||||||
|
],
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'friendSender',
|
||||||
|
attributes: ['hashedId', 'username'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'friendReceiver',
|
||||||
|
attributes: ['hashedId', 'username'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return friendships.map((friendship) => (
|
||||||
|
friendship.user1Id === userId ? friendship.friendReceiver : friendship.friendSender
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrontendCallbackUrl = () => {
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
if (!frontendUrl) {
|
||||||
|
throw new Error('missingfrontendurl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(OAUTH_CALLBACK_PATH, `${frontendUrl.replace(/\/$/, '')}/`).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeClaims = (claims = {}) => ({
|
||||||
|
subject: claims.sub || claims.subject || '',
|
||||||
|
email: typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null,
|
||||||
|
verified: claims.email_verified === true || claims.email_verified === 'true',
|
||||||
|
name: typeof claims.name === 'string' ? claims.name.trim() : '',
|
||||||
|
preferredUsername: typeof claims.preferred_username === 'string' ? claims.preferred_username.trim() : '',
|
||||||
|
givenName: typeof claims.given_name === 'string' ? claims.given_name.trim() : '',
|
||||||
|
familyName: typeof claims.family_name === 'string' ? claims.family_name.trim() : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizeUsernamePart = (value) => value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
const createUniqueUsername = async (claims, providerSlug) => {
|
||||||
|
const candidates = [
|
||||||
|
claims.preferredUsername,
|
||||||
|
claims.name,
|
||||||
|
claims.email ? claims.email.split('@')[0] : '',
|
||||||
|
`${providerSlug}-${claims.subject.slice(0, 8)}`,
|
||||||
|
]
|
||||||
|
.map(sanitizeUsernamePart)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const base = candidates[0] || `${providerSlug}-${claims.subject.slice(0, 8)}`;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (attempt < 50) {
|
||||||
|
const suffix = attempt === 0 ? '' : String(attempt + 1);
|
||||||
|
const username = `${base}${suffix}`.slice(0, 64);
|
||||||
|
const existing = await User.findOne({ where: { username } });
|
||||||
|
if (!existing) {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${providerSlug}-${claims.subject.slice(0, 12)}`.slice(0, 64);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFallbackEmail = (providerSlug, subject, email) => {
|
||||||
|
if (email) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeSubject = subject.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 32) || crypto.randomBytes(8).toString('hex');
|
||||||
|
return `${providerSlug}-${safeSubject}@oauth.local`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderDefinitions = () => STATIC_PROVIDER_DEFS.map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
configured: Boolean(provider.issuer) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_ID`]) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_SECRET`]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getProviderDefinition = (providerSlug) => getProviderDefinitions().find((provider) => provider.slug === providerSlug) || null;
|
||||||
|
|
||||||
|
const getProviderConfiguration = async (providerSlug) => {
|
||||||
|
if (providerCache.has(providerSlug)) {
|
||||||
|
return providerCache.get(providerSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProviderDefinition(providerSlug);
|
||||||
|
if (!provider || !provider.configured) {
|
||||||
|
throw new Error('providernotconfigured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_ID`];
|
||||||
|
const clientSecret = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_SECRET`];
|
||||||
|
const configuration = await oidc.discovery(new URL(provider.issuer), clientId, clientSecret);
|
||||||
|
|
||||||
|
providerCache.set(providerSlug, configuration);
|
||||||
|
return configuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeOAuthState = async (state, payload) => {
|
||||||
|
await redisClient.sendCommand([
|
||||||
|
'SET',
|
||||||
|
`oauth-state:${state}`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
'EX',
|
||||||
|
String(OAUTH_STATE_TTL_SECONDS)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOAuthState = async (state) => {
|
||||||
|
const raw = await redisClient.sendCommand(['GET', `oauth-state:${state}`]);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisClient.sendCommand(['DEL', `oauth-state:${state}`]);
|
||||||
|
return JSON.parse(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findUserByEmail = async (email) => {
|
||||||
|
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
|
||||||
|
const query = `
|
||||||
|
SELECT id FROM community."user"
|
||||||
|
WHERE email = ANY(:encryptedEmails)
|
||||||
|
`;
|
||||||
|
const rows = await sequelize.query(query, {
|
||||||
|
replacements: { encryptedEmails: encryptedEmailCandidates },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User.findByPk(rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUserParams = async (userId) => {
|
||||||
|
const params = await UserParam.findAll({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: {
|
||||||
|
description: {
|
||||||
|
[Op.in]: ['birthdate', 'gender', 'language']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedParams = params.map((param) => ({
|
||||||
|
name: param.paramType.description,
|
||||||
|
value: param.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const languageEntry = mappedParams.find((param) => param.name === 'language');
|
||||||
|
if (languageEntry?.value) {
|
||||||
|
const uiLocaleCodes = ['de', 'en', 'ceb', 'es', 'fr'];
|
||||||
|
if (!uiLocaleCodes.includes(languageEntry.value)) {
|
||||||
|
const numericValue = Number.parseInt(languageEntry.value, 10);
|
||||||
|
const lookupId = Number.isNaN(numericValue) ? languageEntry.value : numericValue;
|
||||||
|
const mappedValue = await UserParamValue.findOne({ where: { id: lookupId } });
|
||||||
|
if (mappedValue?.value && uiLocaleCodes.includes(mappedValue.value)) {
|
||||||
|
languageEntry.value = mappedValue.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLoginPayload = async (user) => ({
|
||||||
|
id: user.hashedId,
|
||||||
|
username: user.username,
|
||||||
|
active: user.active,
|
||||||
|
param: await loadUserParams(user.id),
|
||||||
|
authCode: user.authCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setOAuthSession = async (user) => {
|
||||||
|
const authCode = crypto.randomBytes(20).toString('hex');
|
||||||
|
user.authCode = authCode;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const friends = await getFriends(user.id);
|
||||||
|
for (const friend of friends) {
|
||||||
|
await notifyUser(friend.hashedId, 'friendloginchanged', {
|
||||||
|
userId: user.hashedId,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await setUserSession(user.id, {
|
||||||
|
id: user.hashedId,
|
||||||
|
username: user.username,
|
||||||
|
active: user.active,
|
||||||
|
authCode,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildLoginPayload(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUserFromClaims = async (providerSlug, providerConfiguration, claims) => {
|
||||||
|
const email = buildFallbackEmail(providerSlug, claims.subject, claims.email);
|
||||||
|
const username = await createUniqueUsername({ ...claims, email }, providerSlug);
|
||||||
|
const hashedPassword = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), saltRounds);
|
||||||
|
|
||||||
|
const user = await User.create({
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
active: true,
|
||||||
|
searchable: true,
|
||||||
|
registrationDate: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await OAuthIdentity.create({
|
||||||
|
userId: user.id,
|
||||||
|
provider: providerSlug,
|
||||||
|
issuer: providerConfiguration.serverMetadata().issuer,
|
||||||
|
subject: claims.subject,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.reload();
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkIdentityToUser = async (user, providerSlug, providerConfiguration, claims) => {
|
||||||
|
const existing = await OAuthIdentity.findOne({
|
||||||
|
where: {
|
||||||
|
provider: providerSlug,
|
||||||
|
subject: claims.subject,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.userId !== user.id) {
|
||||||
|
throw new Error('oauthidentityconflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OAuthIdentity.create({
|
||||||
|
userId: user.id,
|
||||||
|
provider: providerSlug,
|
||||||
|
issuer: providerConfiguration.serverMetadata().issuer,
|
||||||
|
subject: claims.subject,
|
||||||
|
email: claims.email || user.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOAuthProviders = async () => getProviderDefinitions().filter((provider) => provider.configured);
|
||||||
|
|
||||||
|
export const startOAuthLogin = async ({ providerSlug }) => {
|
||||||
|
const provider = getProviderDefinition(providerSlug);
|
||||||
|
if (!provider || !provider.configured) {
|
||||||
|
throw new Error('providernotconfigured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = await getProviderConfiguration(providerSlug);
|
||||||
|
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = oidc.randomState();
|
||||||
|
const redirectUri = getFrontendCallbackUrl();
|
||||||
|
|
||||||
|
await storeOAuthState(state, {
|
||||||
|
providerSlug,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return oidc.buildAuthorizationUrl(configuration, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: provider.scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
prompt: 'select_account',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exchangeOAuthLogin = async ({ code, state }) => {
|
||||||
|
if (!code || !state) {
|
||||||
|
throw new Error('oauthcodemissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateData = await loadOAuthState(state);
|
||||||
|
if (!stateData?.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) {
|
||||||
|
throw new Error('oauthstatemissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProviderDefinition(stateData.providerSlug);
|
||||||
|
if (!provider || !provider.configured) {
|
||||||
|
throw new Error('providernotconfigured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = await getProviderConfiguration(stateData.providerSlug);
|
||||||
|
const callbackUrl = new URL(stateData.redirectUri);
|
||||||
|
callbackUrl.searchParams.set('code', code);
|
||||||
|
callbackUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, {
|
||||||
|
pkceCodeVerifier: stateData.codeVerifier,
|
||||||
|
expectedState: state,
|
||||||
|
});
|
||||||
|
|
||||||
|
let claims = typeof tokens.claims === 'function' ? tokens.claims() : null;
|
||||||
|
if (!claims && tokens.access_token) {
|
||||||
|
claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClaims = normalizeClaims(claims || {});
|
||||||
|
if (!normalizedClaims.subject) {
|
||||||
|
throw new Error('oauthsubjectmissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = await OAuthIdentity.findOne({
|
||||||
|
where: {
|
||||||
|
provider: provider.slug,
|
||||||
|
subject: normalizedClaims.subject,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let user = identity ? await User.findByPk(identity.userId) : null;
|
||||||
|
|
||||||
|
if (!user && normalizedClaims.email) {
|
||||||
|
user = await findUserByEmail(normalizedClaims.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await createUserFromClaims(provider.slug, configuration, normalizedClaims);
|
||||||
|
} else {
|
||||||
|
await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims);
|
||||||
|
if (!user.active) {
|
||||||
|
user.active = true;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.active) {
|
||||||
|
user.active = true;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return setOAuthSession(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logoutOAuthUser = async (hashedUserId) => {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const friends = await getFriends(user.id);
|
||||||
|
for (const friend of friends) {
|
||||||
|
await notifyUser(friend.hashedId, 'friendloginchanged', {
|
||||||
|
userId: user.hashedId,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUserSession(user.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserOAuthIdentities = async (userId) => {
|
||||||
|
const identities = await OAuthIdentity.findAll({
|
||||||
|
where: { userId },
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
return identities.map(identity => ({
|
||||||
|
id: identity.id,
|
||||||
|
provider: identity.provider,
|
||||||
|
email: identity.email,
|
||||||
|
createdAt: identity.createdAt,
|
||||||
|
displayName: `${identity.provider} (${identity.email || identity.subject.slice(0, 10)})`
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startOAuthLoginForUser = async ({ userId, providerSlug }) => {
|
||||||
|
const provider = getProviderDefinition(providerSlug);
|
||||||
|
if (!provider || !provider.configured) {
|
||||||
|
throw new Error('providernotconfigured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = await getProviderConfiguration(providerSlug);
|
||||||
|
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = oidc.randomState();
|
||||||
|
const callbackPath = '/auth/oauth/user/callback';
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
if (!frontendUrl) {
|
||||||
|
throw new Error('missingfrontendurl');
|
||||||
|
}
|
||||||
|
const redirectUri = new URL(callbackPath, `${frontendUrl.replace(/\/$/, '')}/`).toString();
|
||||||
|
|
||||||
|
await storeOAuthState(state, {
|
||||||
|
userId,
|
||||||
|
providerSlug,
|
||||||
|
codeVerifier,
|
||||||
|
redirectUri,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return oidc.buildAuthorizationUrl(configuration, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: provider.scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
prompt: 'select_account',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exchangeOAuthLoginForUser = async ({ userId, code, state }) => {
|
||||||
|
if (!code || !state) {
|
||||||
|
throw new Error('oauthcodemissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateData = await loadOAuthState(state);
|
||||||
|
if (!stateData?.userId || !stateData.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) {
|
||||||
|
throw new Error('oauthstatemissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateData.userId !== userId) {
|
||||||
|
throw new Error('oauthuseridmismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProviderDefinition(stateData.providerSlug);
|
||||||
|
if (!provider || !provider.configured) {
|
||||||
|
throw new Error('providernotconfigured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = await getProviderConfiguration(stateData.providerSlug);
|
||||||
|
const callbackUrl = new URL(stateData.redirectUri);
|
||||||
|
callbackUrl.searchParams.set('code', code);
|
||||||
|
callbackUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, {
|
||||||
|
pkceCodeVerifier: stateData.codeVerifier,
|
||||||
|
expectedState: state,
|
||||||
|
});
|
||||||
|
|
||||||
|
let claims = typeof tokens.claims === 'function' ? tokens.claims() : null;
|
||||||
|
if (!claims && tokens.access_token) {
|
||||||
|
claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClaims = normalizeClaims(claims || {});
|
||||||
|
if (!normalizedClaims.subject) {
|
||||||
|
throw new Error('oauthsubjectmissing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('usernotiound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIdentity = await OAuthIdentity.findOne({
|
||||||
|
where: {
|
||||||
|
provider: provider.slug,
|
||||||
|
subject: normalizedClaims.subject,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingIdentity && existingIdentity.userId !== userId) {
|
||||||
|
throw new Error('oauthidentityalreadylinked');
|
||||||
|
}
|
||||||
|
|
||||||
|
await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
identity: {
|
||||||
|
id: existingIdentity?.id,
|
||||||
|
provider: provider.slug,
|
||||||
|
email: normalizedClaims.email,
|
||||||
|
displayName: `${provider.label} (${normalizedClaims.email || normalizedClaims.subject.slice(0, 10)})`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeOAuthIdentity = async ({ userId, identityId }) => {
|
||||||
|
const identity = await OAuthIdentity.findByPk(identityId);
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error('identitynotfound');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identity.userId !== userId) {
|
||||||
|
throw new Error('forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
await identity.destroy();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
const ActivateView = () => import('../views/auth/ActivateView.vue');
|
const ActivateView = () => import('../views/auth/ActivateView.vue');
|
||||||
|
const OAuthCallbackView = () => import('../views/auth/OAuthCallbackView.vue');
|
||||||
|
const OAuthUserCallbackView = () => import('../views/auth/OAuthUserCallbackView.vue');
|
||||||
|
|
||||||
const authRoutes = [
|
const authRoutes = [
|
||||||
{
|
{
|
||||||
@@ -9,6 +11,22 @@ const authRoutes = [
|
|||||||
robots: 'noindex, nofollow'
|
robots: 'noindex, nofollow'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/oauth/callback',
|
||||||
|
name: 'OAuth callback',
|
||||||
|
component: OAuthCallbackView,
|
||||||
|
meta: {
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/oauth/user/callback',
|
||||||
|
name: 'OAuth user callback',
|
||||||
|
component: OAuthUserCallbackView,
|
||||||
|
meta: {
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default authRoutes;
|
export default authRoutes;
|
||||||
|
|||||||
83
frontend/src/views/auth/OAuthCallbackView.vue
Normal file
83
frontend/src/views/auth/OAuthCallbackView.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="oauth-callback-view">
|
||||||
|
<div class="oauth-callback-card surface-card">
|
||||||
|
<p class="oauth-callback-kicker">{{ $t('home.nologin.oauth.callbackKicker') }}</p>
|
||||||
|
<h1>{{ $t('home.nologin.oauth.callbackTitle') }}</h1>
|
||||||
|
<p v-if="!hasError">{{ $t('home.nologin.oauth.callbackText') }}</p>
|
||||||
|
<p v-else class="oauth-callback-error">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions } from 'vuex';
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OAuthCallbackView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasError: false,
|
||||||
|
errorMessage: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['login']),
|
||||||
|
async finishLogin() {
|
||||||
|
const { code, state, error, error_description: errorDescription } = this.$route.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.errorMessage = errorDescription || error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.errorMessage = this.$t('home.nologin.oauth.callbackMissing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/auth/oauth/exchange', { code, state });
|
||||||
|
await this.login({ user: response.data, rememberMe: true });
|
||||||
|
await this.$router.replace('/settings/personal');
|
||||||
|
} catch (loginError) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.errorMessage = loginError?.response?.data?.error || this.$t('home.nologin.oauth.callbackFailure');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.finishLogin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oauth-callback-view {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-card {
|
||||||
|
width: min(100%, 520px);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-kicker {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-error {
|
||||||
|
color: #a94442;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
frontend/src/views/auth/OAuthUserCallbackView.vue
Normal file
98
frontend/src/views/auth/OAuthUserCallbackView.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="oauth-callback-view">
|
||||||
|
<div class="oauth-callback-card surface-card">
|
||||||
|
<p class="oauth-callback-kicker">{{ title }}</p>
|
||||||
|
<h1>{{ message }}</h1>
|
||||||
|
<p v-if="!hasError" class="oauth-callback-status">{{ statusText }}</p>
|
||||||
|
<p v-else class="oauth-callback-error">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OAuthUserCallbackView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasError: false,
|
||||||
|
errorMessage: '',
|
||||||
|
message: 'Authentifizierung wird verknüpft...',
|
||||||
|
title: 'Externe Authentifizierung',
|
||||||
|
statusText: 'Bitte warten...'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async finishLinking() {
|
||||||
|
const { code, state, error, error_description: errorDescription } = this.$route.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.message = 'Fehler bei der Authentifizierung';
|
||||||
|
this.errorMessage = errorDescription || error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.message = 'Fehler';
|
||||||
|
this.errorMessage = 'Code oder State fehlen. Bitte versuche es erneut.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.statusText = 'Authentifizierung wird mit deinem Konto verknüpft...';
|
||||||
|
const response = await apiClient.post('/api/auth/oauth/user/exchange', { code, state });
|
||||||
|
|
||||||
|
this.message = 'Erfolgreich verknüpft!';
|
||||||
|
this.statusText = `${response.data.identity.displayName} wurde erfolgreich hinzugefügt. Du kannst diese Seite jetzt schließen oder zur Sicherheitsseite zurückkehren.`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.$router.replace('/settings/personal');
|
||||||
|
}, 3000);
|
||||||
|
} catch (linkError) {
|
||||||
|
this.hasError = true;
|
||||||
|
this.message = 'Fehler beim Verknüpfen';
|
||||||
|
this.errorMessage = linkError?.response?.data?.error || 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
|
||||||
|
console.error('OAuth user linking error:', linkError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.finishLinking();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oauth-callback-view {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-card {
|
||||||
|
width: min(100%, 520px);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-kicker {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-status {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-callback-error {
|
||||||
|
color: #a94442;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -56,6 +56,25 @@
|
|||||||
<button type="button" class="secondary-action" @click="openRegisterDialog">{{ $t('home.nologin.login.register') }}</button>
|
<button type="button" class="secondary-action" @click="openRegisterDialog">{{ $t('home.nologin.login.register') }}</button>
|
||||||
<button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
<button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="oauth-section" v-if="oauthProviders.length">
|
||||||
|
<div class="oauth-section__header">
|
||||||
|
<span class="panel-kicker">Externe Konten</span>
|
||||||
|
<p class="oauth-section__text">Google, Microsoft, Keycloak, ORY oder ZITADEL nutzen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="oauth-provider-list">
|
||||||
|
<button
|
||||||
|
v-for="provider in oauthProviders"
|
||||||
|
:key="provider.slug"
|
||||||
|
type="button"
|
||||||
|
class="oauth-provider-button"
|
||||||
|
:class="`oauth-provider-button--${provider.slug}`"
|
||||||
|
:disabled="oauthLoading"
|
||||||
|
@click="startOAuthLogin(provider.slug)"
|
||||||
|
>
|
||||||
|
Mit {{ provider.label }} fortfahren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="login-fields">
|
<div class="login-fields">
|
||||||
<input ref="usernameInput" v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
|
<input ref="usernameInput" v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
|
||||||
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
|
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
|
||||||
@@ -149,6 +168,8 @@ export default {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
rememberMe: true,
|
rememberMe: true,
|
||||||
|
oauthProviders: [],
|
||||||
|
oauthLoading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@@ -159,6 +180,10 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['login']),
|
...mapActions(['login']),
|
||||||
|
getOAuthBaseUrl() {
|
||||||
|
const baseUrl = apiClient.defaults.baseURL || window.location.origin;
|
||||||
|
return String(baseUrl).replace(/\/api\/?$/, '').replace(/\/$/, '');
|
||||||
|
},
|
||||||
openRandomChat() {
|
openRandomChat() {
|
||||||
const dlg = this.$refs.randomChatDialog;
|
const dlg = this.$refs.randomChatDialog;
|
||||||
if (dlg && typeof dlg.open === 'function') dlg.open();
|
if (dlg && typeof dlg.open === 'function') dlg.open();
|
||||||
@@ -171,6 +196,23 @@ export default {
|
|||||||
const dlg = this.$refs.passwordResetDialog;
|
const dlg = this.$refs.passwordResetDialog;
|
||||||
if (dlg && typeof dlg.open === 'function') dlg.open();
|
if (dlg && typeof dlg.open === 'function') dlg.open();
|
||||||
},
|
},
|
||||||
|
async loadOAuthProviders() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/api/auth/oauth/providers');
|
||||||
|
this.oauthProviders = (response.data.providers || []).filter((provider) => provider.configured);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading OAuth providers:', error);
|
||||||
|
this.oauthProviders = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startOAuthLogin(providerSlug) {
|
||||||
|
if (this.oauthLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.oauthLoading = true;
|
||||||
|
window.location.href = `${this.getOAuthBaseUrl()}/api/auth/oauth/${encodeURIComponent(providerSlug)}/start`;
|
||||||
|
},
|
||||||
focusPassword() {
|
focusPassword() {
|
||||||
this.$refs.passwordInput.focus();
|
this.$refs.passwordInput.focus();
|
||||||
},
|
},
|
||||||
@@ -184,6 +226,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async created() {
|
||||||
|
await this.loadOAuthProviders();
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.usernameInput?.focus?.();
|
this.$refs.usernameInput?.focus?.();
|
||||||
@@ -365,6 +410,72 @@ export default {
|
|||||||
margin-bottom: 0.1rem;
|
margin-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oauth-section {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 248, 238, 0.95), rgba(255, 243, 229, 0.95));
|
||||||
|
border: 1px solid rgba(248, 162, 43, 0.14);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-section__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-section__text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button {
|
||||||
|
padding: 0.72rem 0.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(93, 64, 55, 0.12);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(247, 242, 236, 0.94));
|
||||||
|
color: #4b342e;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 6px 16px rgba(93, 64, 55, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--google {
|
||||||
|
border-left: 4px solid #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--microsoft {
|
||||||
|
border-left: 4px solid #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--keycloak {
|
||||||
|
border-left: 4px solid #d67f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--ory {
|
||||||
|
border-left: 4px solid #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--zitadel {
|
||||||
|
border-left: 4px solid #0f5132;
|
||||||
|
}
|
||||||
|
|
||||||
.login-fields {
|
.login-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -513,7 +624,8 @@ export default {
|
|||||||
|
|
||||||
.story-columns,
|
.story-columns,
|
||||||
.access-split,
|
.access-split,
|
||||||
.login-fields {
|
.login-fields,
|
||||||
|
.oauth-provider-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,106 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="account-settings">
|
<div class="account-settings">
|
||||||
<section class="account-settings__hero surface-card">
|
<section class="account-settings__hero surface-card">
|
||||||
<span class="account-settings__eyebrow">{{ $t("settings.account.heroEyebrow") }}</span>
|
<span class="account-settings__eyebrow">{{ $t("settings.security.heroEyebrow") }}</span>
|
||||||
<h2>{{ $t("settings.account.title") }}</h2>
|
<h2>{{ $t("settings.security.title") }}</h2>
|
||||||
<p>{{ $t("settings.account.heroIntro") }}</p>
|
<p>{{ $t("settings.security.heroIntro") }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="account-settings__panel surface-card">
|
<section class="account-settings__panel surface-card">
|
||||||
<div class="account-settings__grid">
|
<div class="account-settings__grid">
|
||||||
<label class="account-settings__field">
|
<label class="account-settings__field">
|
||||||
<span>{{ $t("settings.account.username") }}</span>
|
<span>{{ $t("settings.security.username") }}</span>
|
||||||
<input type="text" v-model="username" :placeholder="$t('settings.account.username')" />
|
<input type="text" v-model="username" :placeholder="$t('settings.security.username')" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="account-settings__field">
|
<label class="account-settings__field">
|
||||||
<span>{{ $t("settings.account.email") }}</span>
|
<span>{{ $t("settings.security.email") }}</span>
|
||||||
<input type="text" v-model="email" :placeholder="$t('settings.account.email')" />
|
<input type="text" v-model="email" :placeholder="$t('settings.security.email')" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="account-settings__field">
|
<label class="account-settings__field">
|
||||||
<span>{{ $t("settings.account.newpassword") }}</span>
|
<span>{{ $t("settings.security.newpassword") }}</span>
|
||||||
<input type="password" v-model="newpassword" :placeholder="$t('settings.account.newpassword')"
|
<input type="password" v-model="newpassword" :placeholder="$t('settings.security.newpassword')"
|
||||||
autocomplete="new-password" :class="{ 'field-error': newpassword && !isNewPasswordValid }" />
|
autocomplete="new-password" :class="{ 'field-error': newpassword && !isNewPasswordValid }" />
|
||||||
<span v-if="newpassword && !isNewPasswordValid" class="form-error">{{ $t("settings.account.validation.newPasswordTooShort") }}</span>
|
<span v-if="newpassword && !isNewPasswordValid" class="form-error">{{ $t("settings.security.validation.newPasswordTooShort") }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="account-settings__field">
|
<label class="account-settings__field">
|
||||||
<span>{{ $t("settings.account.newpasswordretype") }}</span>
|
<span>{{ $t("settings.security.newpasswordretype") }}</span>
|
||||||
<input type="password" v-model="newpasswordretype"
|
<input type="password" v-model="newpasswordretype"
|
||||||
:placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password"
|
:placeholder="$t('settings.security.newpasswordretype')" autocomplete="new-password"
|
||||||
:class="{ 'field-error': newpasswordretype && !passwordsMatch }" />
|
:class="{ 'field-error': newpasswordretype && !passwordsMatch }" />
|
||||||
<span v-if="newpasswordretype && !passwordsMatch" class="form-error">{{ $t("settings.account.validation.passwordMismatch") }}</span>
|
<span v-if="newpasswordretype && !passwordsMatch" class="form-error">{{ $t("settings.security.validation.passwordMismatch") }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="account-settings__field account-settings__field--full">
|
<label class="account-settings__field account-settings__field--full">
|
||||||
<span>{{ $t("settings.account.oldpassword") }}</span>
|
<span>{{ $t("settings.security.oldpassword") }}</span>
|
||||||
<input type="password" v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')"
|
<input type="password" v-model="oldpassword" :placeholder="$t('settings.security.oldpassword')"
|
||||||
autocomplete="current-password" :class="{ 'field-error': requiresOldPassword && !oldpassword.trim() }" />
|
autocomplete="current-password" :class="{ 'field-error': requiresOldPassword && !oldpassword.trim() }" />
|
||||||
<span v-if="requiresOldPassword && !oldpassword.trim()" class="form-error">{{ $t("settings.account.validation.oldPasswordRequired") }}</span>
|
<span v-if="requiresOldPassword && !oldpassword.trim()" class="form-error">{{ $t("settings.security.validation.oldPasswordRequired") }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="account-settings__toggle">
|
<label class="account-settings__toggle">
|
||||||
<input type="checkbox" v-model="showInSearch" />
|
<input type="checkbox" v-model="showInSearch" />
|
||||||
<span>{{ $t("settings.account.showinsearch") }}</span>
|
<span>{{ $t("settings.security.showinsearch") }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<section class="account-settings__oauth surface-card">
|
||||||
|
<h3>{{ $t("settings.security.oauthTitle") }}</h3>
|
||||||
|
<p>{{ $t("settings.security.oauthIntro") }}</p>
|
||||||
|
|
||||||
|
<div v-if="oauthIdentities.length > 0" class="account-settings__oauth-list">
|
||||||
|
<div v-for="identity in oauthIdentities" :key="identity.id" class="oauth-identity-item">
|
||||||
|
<div class="oauth-identity-item__info">
|
||||||
|
<strong>{{ identity.displayName }}</strong>
|
||||||
|
<span class="oauth-identity-item__date">{{ formatDate(identity.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oauth-identity-item__remove"
|
||||||
|
@click="removeOAuthIdentity(identity.id)"
|
||||||
|
:disabled="oauthLoading"
|
||||||
|
title="Authentifizierung entfernen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="oauth-identity-item__empty">{{ $t("settings.security.oauthNoIdentities") }}</p>
|
||||||
|
|
||||||
|
<div class="account-settings__oauth-actions">
|
||||||
|
<div v-if="availableProviders.length > 0" class="oauth-provider-buttons">
|
||||||
|
<button
|
||||||
|
v-for="provider in availableProviders"
|
||||||
|
:key="provider.slug"
|
||||||
|
type="button"
|
||||||
|
class="oauth-provider-button"
|
||||||
|
:class="`oauth-provider-button--${provider.slug}`"
|
||||||
|
:disabled="oauthLoading"
|
||||||
|
@click="startAddOAuthIdentity(provider.slug)"
|
||||||
|
>
|
||||||
|
{{ provider.label }} hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="account-settings__adult surface-card">
|
<section class="account-settings__adult surface-card">
|
||||||
<h3>{{ $t("settings.account.adultAccessTitle") }}</h3>
|
<h3>{{ $t("settings.security.adultAccessTitle") }}</h3>
|
||||||
<p>{{ $t("settings.account.adultAccessIntro") }}</p>
|
<p>{{ $t("settings.security.adultAccessIntro") }}</p>
|
||||||
<div class="account-settings__adult-status">
|
<div class="account-settings__adult-status">
|
||||||
<strong>{{ adultStatusTitle }}</strong>
|
<strong>{{ adultStatusTitle }}</strong>
|
||||||
<span>{{ adultStatusText }}</span>
|
<span>{{ adultStatusText }}</span>
|
||||||
<span v-if="adultVerificationRequest?.originalName">{{ adultVerificationRequest.originalName }}</span>
|
<span v-if="adultVerificationRequest?.originalName">{{ adultVerificationRequest.originalName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="account-settings__adult-actions" v-if="canRequestAdultVerification">
|
<div class="account-settings__adult-actions" v-if="canRequestAdultVerification">
|
||||||
<router-link to="/socialnetwork/erotic/access">{{ $t("settings.account.requestAdultVerification") }}</router-link>
|
<router-link to="/socialnetwork/erotic/access">{{ $t("settings.security.requestAdultVerification") }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="account-settings__actions">
|
<div class="account-settings__actions">
|
||||||
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
<button @click="changeAccount" :disabled="oauthLoading">{{ $t("settings.security.changeaction") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,6 +125,9 @@ export default {
|
|||||||
isAdult: false,
|
isAdult: false,
|
||||||
adultVerificationStatus: "none",
|
adultVerificationStatus: "none",
|
||||||
adultVerificationRequest: null,
|
adultVerificationRequest: null,
|
||||||
|
oauthIdentities: [],
|
||||||
|
oauthProviders: [],
|
||||||
|
oauthLoading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -103,18 +146,24 @@ export default {
|
|||||||
},
|
},
|
||||||
adultStatusTitle() {
|
adultStatusTitle() {
|
||||||
if (!this.isAdult) {
|
if (!this.isAdult) {
|
||||||
return this.$t('settings.account.adultStatus.ineligible.title');
|
return this.$t('settings.security.adultStatus.ineligible.title');
|
||||||
}
|
}
|
||||||
return this.$t(`settings.account.adultStatus.${this.adultVerificationStatus}.title`);
|
return this.$t(`settings.security.adultStatus.${this.adultVerificationStatus}.title`);
|
||||||
},
|
},
|
||||||
adultStatusText() {
|
adultStatusText() {
|
||||||
if (!this.isAdult) {
|
if (!this.isAdult) {
|
||||||
return this.$t('settings.account.adultStatus.ineligible.body');
|
return this.$t('settings.security.adultStatus.ineligible.body');
|
||||||
}
|
}
|
||||||
return this.$t(`settings.account.adultStatus.${this.adultVerificationStatus}.body`);
|
return this.$t(`settings.security.adultStatus.${this.adultVerificationStatus}.body`);
|
||||||
|
},
|
||||||
|
availableProviders() {
|
||||||
|
return this.oauthProviders.filter(p => p.configured && !this.oauthIdentities.some(id => id.provider === p.slug));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
formatDate(date) {
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
},
|
||||||
async loadAccount() {
|
async loadAccount() {
|
||||||
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
||||||
this.username = response.data.username;
|
this.username = response.data.username;
|
||||||
@@ -124,6 +173,48 @@ export default {
|
|||||||
this.adultVerificationStatus = response.data.adultVerificationStatus || 'none';
|
this.adultVerificationStatus = response.data.adultVerificationStatus || 'none';
|
||||||
this.adultVerificationRequest = response.data.adultVerificationRequest || null;
|
this.adultVerificationRequest = response.data.adultVerificationRequest || null;
|
||||||
},
|
},
|
||||||
|
async loadOAuthIdentities() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/api/auth/oauth/user/identities');
|
||||||
|
this.oauthIdentities = response.data.identities || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading OAuth identities:', error);
|
||||||
|
this.oauthIdentities = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadOAuthProviders() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/api/auth/oauth/providers');
|
||||||
|
this.oauthProviders = response.data.providers || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading OAuth providers:', error);
|
||||||
|
this.oauthProviders = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startAddOAuthIdentity(providerSlug) {
|
||||||
|
if (this.oauthLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.oauthLoading = true;
|
||||||
|
window.location.href = `/api/auth/oauth/user/${encodeURIComponent(providerSlug)}/start`;
|
||||||
|
},
|
||||||
|
async removeOAuthIdentity(identityId) {
|
||||||
|
if (!confirm('Möchtest du diese Authentifizierung wirklich entfernen?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.oauthLoading = true;
|
||||||
|
await apiClient.delete(`/api/auth/oauth/user/${identityId}`);
|
||||||
|
showSuccess(this, 'Authentifizierung entfernt');
|
||||||
|
await this.loadOAuthIdentities();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing OAuth identity:', error);
|
||||||
|
showApiError(this, error, 'Fehler beim Entfernen der Authentifizierung');
|
||||||
|
} finally {
|
||||||
|
this.oauthLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
registerSocketListeners(sock) {
|
registerSocketListeners(sock) {
|
||||||
if (!sock) return;
|
if (!sock) return;
|
||||||
this._adultVerificationChangedHandler = async (payload = {}) => {
|
this._adultVerificationChangedHandler = async (payload = {}) => {
|
||||||
@@ -149,18 +240,18 @@ export default {
|
|||||||
|
|
||||||
if (hasNewPassword) {
|
if (hasNewPassword) {
|
||||||
if (!this.isNewPasswordValid) {
|
if (!this.isNewPasswordValid) {
|
||||||
showError(this, 'tr:settings.account.validation.newPasswordTooShort');
|
showError(this, 'tr:settings.security.validation.newPasswordTooShort');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
|
// Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
|
||||||
if (!this.passwordsMatch) {
|
if (!this.passwordsMatch) {
|
||||||
showError(this, 'tr:settings.account.validation.passwordMismatch');
|
showError(this, 'tr:settings.security.validation.passwordMismatch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe ob das alte Passwort eingegeben wurde
|
// Prüfe ob das alte Passwort eingegeben wurde
|
||||||
if (!this.oldpassword || this.oldpassword.trim() === '') {
|
if (!this.oldpassword || this.oldpassword.trim() === '') {
|
||||||
showError(this, 'tr:settings.account.validation.oldPasswordRequired');
|
showError(this, 'tr:settings.security.validation.oldPasswordRequired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +275,7 @@ export default {
|
|||||||
// API-Aufruf zum Speichern der Account-Einstellungen
|
// API-Aufruf zum Speichern der Account-Einstellungen
|
||||||
await apiClient.post('/api/settings/set-account', accountData);
|
await apiClient.post('/api/settings/set-account', accountData);
|
||||||
|
|
||||||
showSuccess(this, 'tr:settings.account.feedback.saved');
|
showSuccess(this, 'tr:settings.security.feedback.saved');
|
||||||
|
|
||||||
// Leere die Passwort-Felder nach erfolgreichem Speichern
|
// Leere die Passwort-Felder nach erfolgreichem Speichern
|
||||||
this.newpassword = '';
|
this.newpassword = '';
|
||||||
@@ -193,7 +284,7 @@ export default {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
||||||
showApiError(this, error, 'tr:settings.account.feedback.saveError');
|
showApiError(this, error, 'tr:settings.security.feedback.saveError');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -203,6 +294,8 @@ export default {
|
|||||||
this.registerSocketListeners(this.socket);
|
this.registerSocketListeners(this.socket);
|
||||||
}
|
}
|
||||||
await this.loadAccount();
|
await this.loadAccount();
|
||||||
|
await this.loadOAuthProviders();
|
||||||
|
await this.loadOAuthIdentities();
|
||||||
|
|
||||||
// Stelle sicher, dass Passwort-Felder leer sind
|
// Stelle sicher, dass Passwort-Felder leer sind
|
||||||
this.newpassword = '';
|
this.newpassword = '';
|
||||||
@@ -236,7 +329,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__hero,
|
.account-settings__hero,
|
||||||
.account-settings__panel {
|
.account-settings__panel,
|
||||||
|
.account-settings__adult,
|
||||||
|
.account-settings__oauth {
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -248,107 +343,249 @@ export default {
|
|||||||
padding: 26px 28px;
|
padding: 26px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__eyebrow {
|
.account-settings__panel {
|
||||||
display: inline-flex;
|
flex: 0 0 auto;
|
||||||
margin-bottom: 8px;
|
padding: 28px;
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--color-primary-soft);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__hero p {
|
.account-settings__oauth {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__oauth h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__oauth p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unteres Panel: füllt Resthöhe und scrollt (global .surface-card hat overflow:hidden) */
|
.account-settings__oauth-list {
|
||||||
.account-settings__panel.account-settings__panel {
|
display: grid;
|
||||||
flex: 1 1 0%;
|
gap: 8px;
|
||||||
min-height: 0;
|
}
|
||||||
padding: 24px;
|
|
||||||
overflow-x: hidden;
|
.oauth-identity-item {
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
-webkit-overflow-scrolling: touch;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
border: 1px solid rgba(200, 150, 100, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__info {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__info strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__remove {
|
||||||
|
background: rgba(200, 80, 80, 0.15);
|
||||||
|
border: 1px solid rgba(200, 80, 80, 0.3);
|
||||||
|
color: #a94442;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__remove:hover:not(:disabled) {
|
||||||
|
background: rgba(200, 80, 80, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__remove:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-identity-item__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__oauth-actions {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(200, 150, 100, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(150, 100, 80, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.6), rgba(248, 240, 230, 0.6));
|
||||||
|
color: #5a4a42;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(250, 245, 235, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--google {
|
||||||
|
border-left: 4px solid #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--microsoft {
|
||||||
|
border-left: 4px solid #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--keycloak {
|
||||||
|
border-left: 4px solid #d67f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--ory {
|
||||||
|
border-left: 4px solid #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-provider-button--zitadel {
|
||||||
|
border-left: 4px solid #0f5132;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__adult {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__actions button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(150, 100, 80, 0.3);
|
||||||
|
background: #4a8f3d;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__actions button:hover:not(:disabled) {
|
||||||
|
background: #3d7631;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__actions button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__grid {
|
.account-settings__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__field {
|
.account-settings__field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__field span {
|
.account-settings__field span {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
}
|
||||||
|
|
||||||
|
.account-settings__field input {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(150, 100, 80, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__field--full {
|
.account-settings__field--full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: #a94442;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.account-settings__toggle {
|
.account-settings__toggle {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 18px;
|
cursor: pointer;
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__actions {
|
.account-settings__toggle input[type="checkbox"] {
|
||||||
display: flex;
|
cursor: pointer;
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-settings__adult {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding: 18px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(250, 244, 235, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-settings__adult p,
|
|
||||||
.account-settings__adult-status {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__adult-status {
|
.account-settings__adult-status {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 10px;
|
padding: 12px;
|
||||||
|
background: rgba(248, 240, 230, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__adult-actions {
|
.account-settings__adult-actions {
|
||||||
margin-top: 14px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
.account-settings__adult-actions a {
|
||||||
.account-settings__grid {
|
display: inline-block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #4a8f3d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings__adult-actions a:hover {
|
||||||
|
background: #3d7631;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.account-settings__grid,
|
||||||
|
.oauth-provider-buttons {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-settings__hero,
|
.account-settings__field--full {
|
||||||
.account-settings__panel {
|
grid-column: 1;
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-settings__actions {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-settings__actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user