Implement Google OAuth linking functionality. Update backend to handle linking existing accounts with Google, including state token management. Enhance frontend to support linking process, including new UI components for user input and feedback. Update mobile app to handle OAuth callbacks and integrate linking features. Refactor related services and controllers for improved error handling and user experience.
This commit is contained in:
83
GOOGLE_OAUTH_MANUAL_STEPS.md
Normal file
83
GOOGLE_OAUTH_MANUAL_STEPS.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Google OAuth: manuelle Restarbeiten
|
||||||
|
|
||||||
|
Stand: 2026-05-15
|
||||||
|
|
||||||
|
## 1. Google Cloud Console vorbereiten
|
||||||
|
|
||||||
|
1. In der Google Cloud Console das passende Projekt öffnen oder ein neues Projekt anlegen.
|
||||||
|
2. Unter `APIs & Services > OAuth consent screen` den Consent Screen konfigurieren.
|
||||||
|
3. App-Name, Support-E-Mail und Entwicklerkontakt eintragen.
|
||||||
|
4. Scopes reichen für diese App:
|
||||||
|
- `profile`
|
||||||
|
- `email`
|
||||||
|
5. Unter `APIs & Services > Credentials` einen OAuth-Client vom Typ `Web application` anlegen.
|
||||||
|
6. Als Authorized redirect URI eintragen:
|
||||||
|
- Produktion: `https://stechuhr3.tsschulz.de/api/auth/google/callback`
|
||||||
|
- Lokal, falls benötigt: `http://localhost:3010/api/auth/google/callback`
|
||||||
|
7. `Client ID` und `Client Secret` notieren.
|
||||||
|
|
||||||
|
## 2. Server-Environment setzen
|
||||||
|
|
||||||
|
Auf dem Server in der produktiven Environment-Datei folgende Werte setzen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_CLIENT_ID=<client-id-aus-google-console>
|
||||||
|
GOOGLE_CLIENT_SECRET=<client-secret-aus-google-console>
|
||||||
|
GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/google/callback
|
||||||
|
API_PUBLIC_URL=https://stechuhr3.tsschulz.de/api
|
||||||
|
FRONTEND_URL=https://stechuhr3.tsschulz.de
|
||||||
|
SESSION_SECRET=<langer-zufallswert>
|
||||||
|
JWT_SECRET=<bestehender-oder-langer-zufallswert>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: `GOOGLE_CALLBACK_URL` muss exakt mit der Redirect URI in der Google Console übereinstimmen.
|
||||||
|
|
||||||
|
## 3. Backend deployen und neu starten
|
||||||
|
|
||||||
|
1. Code auf den Server bringen.
|
||||||
|
2. Backend neu starten, z. B. über das vorhandene Deploy-Skript oder PM2.
|
||||||
|
3. Log prüfen. Erwartet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
✅ Google OAuth konfiguriert
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn stattdessen `Google OAuth nicht konfiguriert` erscheint, fehlen `GOOGLE_CLIENT_ID` oder `GOOGLE_CLIENT_SECRET` im laufenden Prozess.
|
||||||
|
|
||||||
|
## 4. Web-Frontend deployen
|
||||||
|
|
||||||
|
1. Frontend neu bauen.
|
||||||
|
2. Neue `dist`-Dateien deployen.
|
||||||
|
3. Browser-Cache bei Bedarf leeren.
|
||||||
|
|
||||||
|
## 5. Android-App neu bauen/installieren
|
||||||
|
|
||||||
|
1. Debug/APK oder Release/AAB neu bauen.
|
||||||
|
2. App neu installieren.
|
||||||
|
3. Prüfen, dass Android den Deep Link `timeclock://oauth-callback` öffnet.
|
||||||
|
|
||||||
|
## 6. Testfälle
|
||||||
|
|
||||||
|
1. Google-Account ohne vorhandene TimeClock-E-Mail:
|
||||||
|
- `Mit Google anmelden`
|
||||||
|
- Erwartung: neuer Account wird erstellt und eingeloggt.
|
||||||
|
2. Google-Account bereits verknüpft:
|
||||||
|
- `Mit Google anmelden`
|
||||||
|
- Erwartung: direkter Login.
|
||||||
|
3. Google-Account mit E-Mail eines bestehenden Passwort-Accounts:
|
||||||
|
- `Mit Google anmelden`
|
||||||
|
- Erwartung: Verknüpfungsabfrage.
|
||||||
|
- Bestehende E-Mail + Passwort eingeben.
|
||||||
|
- Erwartung: Account wird verknüpft und eingeloggt.
|
||||||
|
4. Eingeloggt unter `Persönliche Daten`:
|
||||||
|
- `Mit Google-Konto verknüpfen`
|
||||||
|
- Erwartung: Google Flow startet, danach Rückkehr und Verknüpfung.
|
||||||
|
5. Danach ausloggen und erneut `Mit Google anmelden`.
|
||||||
|
- Erwartung: direkter Login in denselben bestehenden Account.
|
||||||
|
|
||||||
|
## 7. Falls etwas fehlschlägt
|
||||||
|
|
||||||
|
- Google meldet `redirect_uri_mismatch`: Redirect URI in Google Console und `GOOGLE_CALLBACK_URL` vergleichen.
|
||||||
|
- Backend zeigt OAuth nicht konfiguriert: PM2/Deploy lädt die neuen Env-Werte nicht. Prozess mit aktualisierter Environment neu starten.
|
||||||
|
- Android kehrt nicht in die App zurück: prüfen, ob der Browser auf `timeclock://oauth-callback?...` weiterleitet und die neu gebaute App installiert ist.
|
||||||
|
- Verknüpfung schlägt fehl: prüfen, ob der alte Account wirklich ein Passwort hat. OAuth-only Accounts können sich nicht per Passwort bestätigen.
|
||||||
@@ -56,7 +56,8 @@ EMAIL_FROM_NAME=TimeClock Zeiterfassung
|
|||||||
# Erstelle OAuth Credentials unter: https://console.cloud.google.com/
|
# Erstelle OAuth Credentials unter: https://console.cloud.google.com/
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/oauth/google/callback
|
GOOGLE_CALLBACK_URL=https://stechuhr3.tsschulz.de/api/auth/google/callback
|
||||||
|
API_PUBLIC_URL=https://stechuhr3.tsschulz.de/api
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SECURITY & CORS
|
# SECURITY & CORS
|
||||||
@@ -88,4 +89,3 @@ BACKUP_DIR=/var/backups/timeclock
|
|||||||
|
|
||||||
# Backup-Retention in Tagen
|
# Backup-Retention in Tagen
|
||||||
BACKUP_RETENTION_DAYS=30
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ class PassportConfig {
|
|||||||
},
|
},
|
||||||
async (accessToken, refreshToken, profile, done) => {
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
try {
|
try {
|
||||||
const result = await oauthService.authenticateWithProvider(profile, 'google');
|
return done(null, { profile, provider: 'google' });
|
||||||
return done(null, result);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return done(error, null);
|
return done(error, null);
|
||||||
}
|
}
|
||||||
@@ -45,4 +44,3 @@ class PassportConfig {
|
|||||||
module.exports = PassportConfig;
|
module.exports = PassportConfig;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
|
const oauthService = require('../services/OAuthService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Controller
|
* OAuth Controller
|
||||||
@@ -10,12 +11,38 @@ class OAuthController {
|
|||||||
* GET /api/auth/google
|
* GET /api/auth/google
|
||||||
*/
|
*/
|
||||||
googleAuth(req, res, next) {
|
googleAuth(req, res, next) {
|
||||||
|
const state = req.query.stateToken || oauthService.createStateToken({
|
||||||
|
platform: req.query.platform === 'android' ? 'android' : 'web'
|
||||||
|
});
|
||||||
passport.authenticate('google', {
|
passport.authenticate('google', {
|
||||||
scope: ['profile', 'email'],
|
scope: ['profile', 'email'],
|
||||||
session: false
|
session: false,
|
||||||
|
state
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google OAuth-Verknüpfung für eingeloggte Benutzer starten
|
||||||
|
* POST /api/auth/google/link-url
|
||||||
|
*/
|
||||||
|
createGoogleLinkUrl(req, res) {
|
||||||
|
try {
|
||||||
|
const platform = req.body?.platform === 'android' ? 'android' : 'web';
|
||||||
|
const stateToken = oauthService.createStateToken({
|
||||||
|
mode: 'link',
|
||||||
|
platform,
|
||||||
|
userId: req.user.userId
|
||||||
|
});
|
||||||
|
const baseUrl = process.env.API_PUBLIC_URL || `${req.protocol}://${req.get('host')}/api`;
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
url: `${baseUrl}/auth/google?stateToken=${encodeURIComponent(stateToken)}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google OAuth Callback
|
* Google OAuth Callback
|
||||||
* GET /api/auth/google/callback
|
* GET /api/auth/google/callback
|
||||||
@@ -24,18 +51,67 @@ class OAuthController {
|
|||||||
passport.authenticate('google', {
|
passport.authenticate('google', {
|
||||||
session: false,
|
session: false,
|
||||||
failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`
|
failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`
|
||||||
}, (err, result) => {
|
}, async (err, result) => {
|
||||||
if (err || !result) {
|
if (err || !result) {
|
||||||
console.error('Google OAuth Fehler:', err);
|
console.error('Google OAuth Fehler:', err);
|
||||||
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
|
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect zum Frontend mit Token
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5010';
|
const state = oauthService.verifyStateToken(req.query.state);
|
||||||
res.redirect(`${frontendUrl}/oauth-callback?token=${result.token}`);
|
const authResult = await oauthService.completeOAuthLogin(result.profile, result.provider, {
|
||||||
|
linkUserId: state.mode === 'link' ? state.userId : null
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = state.platform === 'android'
|
||||||
|
? 'timeclock://oauth-callback'
|
||||||
|
: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/oauth-callback`;
|
||||||
|
|
||||||
|
if (authResult.requiresLink) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pending: authResult.pendingToken,
|
||||||
|
email: authResult.email || '',
|
||||||
|
provider: result.provider
|
||||||
|
});
|
||||||
|
return res.redirect(`${target}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(`${target}?token=${encodeURIComponent(authResult.token)}`);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error('Google OAuth Callback-Verarbeitung fehlgeschlagen:', callbackError);
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
|
||||||
|
}
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async linkExistingAccount(req, res) {
|
||||||
|
try {
|
||||||
|
const { pendingToken, email, password } = req.body;
|
||||||
|
if (!pendingToken || !email || !password) {
|
||||||
|
return res.status(400).json({ success: false, error: 'pendingToken, E-Mail und Passwort sind erforderlich' });
|
||||||
|
}
|
||||||
|
const result = await oauthService.linkPendingToPasswordAccount(pendingToken, email, password);
|
||||||
|
res.json({ success: true, token: result.token, user: result.user });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth-Verknüpfung mit bestehendem Account fehlgeschlagen:', error);
|
||||||
|
res.status(401).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkPendingToCurrentUser(req, res) {
|
||||||
|
try {
|
||||||
|
const { pendingToken } = req.body;
|
||||||
|
if (!pendingToken) {
|
||||||
|
return res.status(400).json({ success: false, error: 'pendingToken ist erforderlich' });
|
||||||
|
}
|
||||||
|
const result = await oauthService.linkPendingToAuthenticatedUser(pendingToken, req.user.userId);
|
||||||
|
res.json({ success: true, token: result.token, user: result.user });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth-Verknüpfung fehlgeschlagen:', error);
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth-Identities für Benutzer abrufen
|
* OAuth-Identities für Benutzer abrufen
|
||||||
* GET /api/auth/identities
|
* GET /api/auth/identities
|
||||||
@@ -43,8 +119,6 @@ class OAuthController {
|
|||||||
async getIdentities(req, res) {
|
async getIdentities(req, res) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const oauthService = require('../services/OAuthService');
|
|
||||||
|
|
||||||
const identities = await oauthService.getUserIdentities(userId);
|
const identities = await oauthService.getUserIdentities(userId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -69,8 +143,6 @@ class OAuthController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const { provider } = req.params;
|
const { provider } = req.params;
|
||||||
const oauthService = require('../services/OAuthService');
|
|
||||||
|
|
||||||
const unlinked = await oauthService.unlinkProvider(userId, provider);
|
const unlinked = await oauthService.unlinkProvider(userId, provider);
|
||||||
|
|
||||||
if (unlinked) {
|
if (unlinked) {
|
||||||
@@ -98,4 +170,3 @@ class OAuthController {
|
|||||||
module.exports = new OAuthController();
|
module.exports = new OAuthController();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,16 @@ router.post('/reset-password', authController.resetPassword.bind(authController)
|
|||||||
// OAuth Routes (öffentlich)
|
// OAuth Routes (öffentlich)
|
||||||
router.get('/google', oauthController.googleAuth.bind(oauthController));
|
router.get('/google', oauthController.googleAuth.bind(oauthController));
|
||||||
router.get('/google/callback', oauthController.googleCallback.bind(oauthController));
|
router.get('/google/callback', oauthController.googleCallback.bind(oauthController));
|
||||||
|
router.post('/oauth/link-existing', oauthController.linkExistingAccount.bind(oauthController));
|
||||||
|
|
||||||
// Geschützte Routes (Auth erforderlich)
|
// Geschützte Routes (Auth erforderlich)
|
||||||
router.post('/logout', authenticateToken, authController.logout.bind(authController));
|
router.post('/logout', authenticateToken, authController.logout.bind(authController));
|
||||||
router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController));
|
router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController));
|
||||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||||
router.get('/validate', authenticateToken, authController.validateToken.bind(authController));
|
router.get('/validate', authenticateToken, authController.validateToken.bind(authController));
|
||||||
|
router.post('/google/link-url', authenticateToken, oauthController.createGoogleLinkUrl.bind(oauthController));
|
||||||
|
router.post('/oauth/link-current', authenticateToken, oauthController.linkPendingToCurrentUser.bind(oauthController));
|
||||||
router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController));
|
router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController));
|
||||||
router.delete('/identity/:provider', authenticateToken, oauthController.unlinkProvider.bind(oauthController));
|
router.delete('/identity/:provider', authenticateToken, oauthController.unlinkProvider.bind(oauthController));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class OAuthService {
|
|||||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||||
*/
|
*/
|
||||||
async authenticateWithProvider(profile, provider) {
|
async authenticateWithProvider(profile, provider) {
|
||||||
|
return this.completeOAuthLogin(profile, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeOAuthLogin(profile, provider, options = {}) {
|
||||||
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
|
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
|
||||||
|
|
||||||
const providerId = profile.id;
|
const providerId = profile.id;
|
||||||
@@ -51,68 +55,150 @@ class OAuthService {
|
|||||||
// Bestehender OAuth-Benutzer
|
// Bestehender OAuth-Benutzer
|
||||||
authInfo = authIdentity.authInfo;
|
authInfo = authIdentity.authInfo;
|
||||||
user = authInfo.user;
|
user = authInfo.user;
|
||||||
} else {
|
if (options.linkUserId && Number(user.id) !== Number(options.linkUserId)) {
|
||||||
// Neuer OAuth-Benutzer oder Verknüpfung mit bestehendem Account
|
throw new Error('Dieser Google-Account ist bereits mit einem anderen Benutzer verknüpft');
|
||||||
|
|
||||||
// Prüfen ob Benutzer mit dieser E-Mail bereits existiert
|
|
||||||
if (email) {
|
|
||||||
authInfo = await AuthInfo.findOne({
|
|
||||||
where: { email },
|
|
||||||
include: [{
|
|
||||||
model: User,
|
|
||||||
as: 'user'
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (options.linkUserId) {
|
||||||
|
authInfo = await AuthInfo.findOne({
|
||||||
|
where: { user_id: options.linkUserId },
|
||||||
|
include: [{ model: User, as: 'user' }]
|
||||||
|
});
|
||||||
|
|
||||||
if (authInfo) {
|
if (!authInfo) {
|
||||||
// Verknüpfe OAuth mit bestehendem Account
|
throw new Error('Benutzer für Verknüpfung nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
authIdentity = await AuthIdentity.create({
|
||||||
|
auth_info_id: authInfo.id,
|
||||||
|
provider,
|
||||||
|
identity: providerId,
|
||||||
|
version: 0
|
||||||
|
});
|
||||||
user = authInfo.user;
|
user = authInfo.user;
|
||||||
|
|
||||||
authIdentity = await AuthIdentity.create({
|
|
||||||
auth_info_id: authInfo.id,
|
|
||||||
provider,
|
|
||||||
identity: providerId,
|
|
||||||
version: 0
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Neuen Benutzer erstellen
|
// Neuer OAuth-Benutzer oder explizite Verknüpfung mit bestehendem Account
|
||||||
user = await User.create({
|
|
||||||
full_name: displayName,
|
|
||||||
role: 0,
|
|
||||||
daily_hours: 8,
|
|
||||||
week_hours: 40,
|
|
||||||
week_workdays: 5,
|
|
||||||
preferred_title_type: 0,
|
|
||||||
version: 0,
|
|
||||||
last_change: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auth-Info erstellen (ohne Passwort für OAuth-only Accounts)
|
// Prüfen ob Benutzer mit dieser E-Mail bereits existiert
|
||||||
authInfo = await AuthInfo.create({
|
if (email) {
|
||||||
user_id: user.id,
|
authInfo = await AuthInfo.findOne({
|
||||||
email: email || `${provider}_${providerId}@oauth.local`,
|
where: { email },
|
||||||
password_hash: '', // Kein Passwort für OAuth-only
|
include: [{
|
||||||
password_method: 'oauth',
|
model: User,
|
||||||
password_salt: '',
|
as: 'user'
|
||||||
status: 1,
|
}]
|
||||||
failed_login_attempts: 0,
|
});
|
||||||
email_token: '',
|
}
|
||||||
email_token_role: 0,
|
|
||||||
unverified_email: '',
|
|
||||||
version: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// OAuth-Identity erstellen
|
if (authInfo) {
|
||||||
authIdentity = await AuthIdentity.create({
|
return {
|
||||||
auth_info_id: authInfo.id,
|
requiresLink: true,
|
||||||
provider,
|
pendingToken: this.createPendingToken(profile, provider),
|
||||||
identity: providerId,
|
email,
|
||||||
version: 0
|
displayName
|
||||||
});
|
};
|
||||||
|
} else {
|
||||||
|
// Neuen Benutzer erstellen
|
||||||
|
user = await User.create({
|
||||||
|
full_name: displayName,
|
||||||
|
role: 0,
|
||||||
|
daily_hours: 8,
|
||||||
|
week_hours: 40,
|
||||||
|
week_workdays: 5,
|
||||||
|
preferred_title_type: 0,
|
||||||
|
version: 0,
|
||||||
|
last_change: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth-Info erstellen (ohne Passwort für OAuth-only Accounts)
|
||||||
|
authInfo = await AuthInfo.create({
|
||||||
|
user_id: user.id,
|
||||||
|
email: email || `${provider}_${providerId}@oauth.local`,
|
||||||
|
password_hash: '', // Kein Passwort für OAuth-only
|
||||||
|
password_method: 'oauth',
|
||||||
|
password_salt: '',
|
||||||
|
status: 1,
|
||||||
|
failed_login_attempts: 0,
|
||||||
|
email_token: '',
|
||||||
|
email_token_role: 0,
|
||||||
|
unverified_email: '',
|
||||||
|
version: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth-Identity erstellen
|
||||||
|
authIdentity = await AuthIdentity.create({
|
||||||
|
auth_info_id: authInfo.id,
|
||||||
|
provider,
|
||||||
|
identity: providerId,
|
||||||
|
version: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.createLoginResult(user, authInfo, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPendingToken(profile, provider) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
providerId: profile.id,
|
||||||
|
email: profile.emails && profile.emails[0] ? profile.emails[0].value : null,
|
||||||
|
displayName: profile.displayName || profile.name || null,
|
||||||
|
type: 'oauth-pending'
|
||||||
|
},
|
||||||
|
this.jwtSecret,
|
||||||
|
{ expiresIn: '15m' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPendingToken(pendingToken) {
|
||||||
|
const data = jwt.verify(pendingToken, this.jwtSecret);
|
||||||
|
if (data.type !== 'oauth-pending' || !data.provider || !data.providerId) {
|
||||||
|
throw new Error('Ungültiger OAuth-Verknüpfungstoken');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
createStateToken(data) {
|
||||||
|
return jwt.sign({ ...data, type: 'oauth-state' }, this.jwtSecret, { expiresIn: '15m' });
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyStateToken(stateToken) {
|
||||||
|
if (!stateToken) return {};
|
||||||
|
const data = jwt.verify(stateToken, this.jwtSecret);
|
||||||
|
if (data.type !== 'oauth-state') {
|
||||||
|
throw new Error('Ungültiger OAuth-State');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkPendingToPasswordAccount(pendingToken, email, password) {
|
||||||
|
const authService = require('./AuthService');
|
||||||
|
const pending = this.verifyPendingToken(pendingToken);
|
||||||
|
const loginResult = await authService.login(email, password, '0');
|
||||||
|
const profile = {
|
||||||
|
id: pending.providerId,
|
||||||
|
displayName: pending.displayName || loginResult.user.full_name,
|
||||||
|
emails: pending.email ? [{ value: pending.email }] : []
|
||||||
|
};
|
||||||
|
return this.completeOAuthLogin(profile, pending.provider, { linkUserId: loginResult.user.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkPendingToAuthenticatedUser(pendingToken, userId) {
|
||||||
|
const pending = this.verifyPendingToken(pendingToken);
|
||||||
|
const profile = {
|
||||||
|
id: pending.providerId,
|
||||||
|
displayName: pending.displayName || null,
|
||||||
|
emails: pending.email ? [{ value: pending.email }] : []
|
||||||
|
};
|
||||||
|
return this.completeOAuthLogin(profile, pending.provider, { linkUserId: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
createLoginResult(user, authInfo, provider) {
|
||||||
|
const { AuthToken } = database.getModels();
|
||||||
|
|
||||||
// JWT Token generieren
|
// JWT Token generieren
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{
|
{
|
||||||
@@ -129,14 +215,12 @@ class OAuthService {
|
|||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||||
|
|
||||||
await AuthToken.create({
|
return AuthToken.create({
|
||||||
auth_info_id: authInfo.id,
|
auth_info_id: authInfo.id,
|
||||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
version: 0
|
version: 0
|
||||||
});
|
}).then(() => ({
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -145,7 +229,7 @@ class OAuthService {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
provider
|
provider
|
||||||
}
|
}
|
||||||
};
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,5 +289,3 @@ class OAuthService {
|
|||||||
|
|
||||||
module.exports = new OAuthService();
|
module.exports = new OAuthService();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="oauth-callback">
|
<div class="oauth-callback">
|
||||||
<div class="loading-container">
|
<div v-if="pendingToken" class="link-container">
|
||||||
|
<h2>Google-Konto verknüpfen</h2>
|
||||||
|
<p>
|
||||||
|
Für {{ googleEmail }} existiert bereits ein Account. Melden Sie sich mit dem bestehenden Account an,
|
||||||
|
um ihn mit Google zu verknüpfen.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="linkExistingAccount" class="link-form">
|
||||||
|
<label>E-Mail-Adresse</label>
|
||||||
|
<input v-model="email" type="email" required>
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input v-model="password" type="password" required>
|
||||||
|
<button class="btn btn-primary" :disabled="linking">
|
||||||
|
{{ linking ? 'Wird verknüpft...' : 'Bestehenden Account verknüpfen' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" @click="router.push('/login')">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading-container">
|
||||||
<h2>{{ status }}</h2>
|
<h2>{{ status }}</h2>
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,16 +29,60 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import { API_BASE_URL } from '@/config/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const API_URL = API_BASE_URL
|
||||||
|
|
||||||
const status = ref('Authentifizierung läuft...')
|
const status = ref('Authentifizierung läuft...')
|
||||||
|
const pendingToken = ref('')
|
||||||
|
const googleEmail = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const linking = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
async function finishLogin(token) {
|
||||||
|
authStore.saveToken(token)
|
||||||
|
await authStore.fetchCurrentUser()
|
||||||
|
status.value = 'Login erfolgreich! Sie werden weitergeleitet...'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/')
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkExistingAccount() {
|
||||||
|
try {
|
||||||
|
linking.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
const response = await fetch(`${API_URL}/auth/oauth/link-existing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pendingToken: pendingToken.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok || !result.success || !result.token) {
|
||||||
|
throw new Error(result.error || 'Verknüpfung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
pendingToken.value = ''
|
||||||
|
await finishLogin(result.token)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Verknüpfung fehlgeschlagen'
|
||||||
|
} finally {
|
||||||
|
linking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const token = route.query.token
|
const token = route.query.token
|
||||||
const error = route.query.error
|
const error = route.query.error
|
||||||
|
const pending = route.query.pending
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
status.value = 'OAuth-Login fehlgeschlagen'
|
status.value = 'OAuth-Login fehlgeschlagen'
|
||||||
@@ -30,19 +92,16 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
pendingToken.value = pending
|
||||||
|
googleEmail.value = route.query.email || ''
|
||||||
|
email.value = route.query.email || ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
// Token speichern
|
await finishLogin(token)
|
||||||
authStore.saveToken(token)
|
|
||||||
|
|
||||||
// Benutzer-Daten laden
|
|
||||||
await authStore.fetchCurrentUser()
|
|
||||||
|
|
||||||
status.value = 'Login erfolgreich! Sie werden weitergeleitet...'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/')
|
|
||||||
}, 1000)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.value = 'Fehler beim Login'
|
status.value = 'Fehler beim Login'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -71,6 +130,34 @@ onMounted(async () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-container {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 24px;
|
||||||
|
width: min(460px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-container h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-form input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d9534f;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-container h2 {
|
.loading-container h2 {
|
||||||
color: #333;
|
color: #333;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -94,4 +181,3 @@ onMounted(async () => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Google-Anmeldung</label>
|
||||||
|
<button type="button" class="btn" @click="linkGoogle" :disabled="loading">
|
||||||
|
Mit Google-Konto verknüpfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
{{ loading ? 'Wird gespeichert...' : 'Einstellungen speichern' }}
|
{{ loading ? 'Wird gespeichert...' : 'Einstellungen speichern' }}
|
||||||
@@ -216,6 +223,29 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function linkGoogle() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch(`${API_URL}/auth/google/link-url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...authStore.getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ platform: 'web' })
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok || !result.url) {
|
||||||
|
throw new Error(result.error || 'Google-Verknüpfung konnte nicht gestartet werden')
|
||||||
|
}
|
||||||
|
window.location.href = result.url
|
||||||
|
} catch (error) {
|
||||||
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initiales Laden
|
// Initiales Laden
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -325,4 +355,3 @@ onMounted(async () => {
|
|||||||
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4);
|
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -11,11 +11,20 @@
|
|||||||
android:theme="@style/Theme.TimeClock">
|
android:theme="@style/Theme.TimeClock">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="timeclock"
|
||||||
|
android:host="oauth-callback" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package de.tsschulz.timeclock
|
package de.tsschulz.timeclock
|
||||||
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.tsschulz.timeclock.ui.TimeClockApp
|
import de.tsschulz.timeclock.ui.TimeClockApp
|
||||||
@@ -17,9 +20,12 @@ import de.tsschulz.timeclock.ui.theme.TimeClockTheme
|
|||||||
import de.tsschulz.timeclock.ui.time.TimeViewModel
|
import de.tsschulz.timeclock.ui.time.TimeViewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val oauthCallback = mutableStateOf<OAuthCallback?>(null)
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
oauthCallback.value = intent.toOAuthCallback()
|
||||||
Log.i(TAG, "MainActivity.onCreate")
|
Log.i(TAG, "MainActivity.onCreate")
|
||||||
window.statusBarColor = Color.rgb(240, 255, 236)
|
window.statusBarColor = Color.rgb(240, 255, 236)
|
||||||
window.navigationBarColor = Color.WHITE
|
window.navigationBarColor = Color.WHITE
|
||||||
@@ -30,6 +36,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
|
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
|
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
|
||||||
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
|
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
|
||||||
|
val callback = oauthCallback.value
|
||||||
|
LaunchedEffect(callback) {
|
||||||
|
when (callback) {
|
||||||
|
is OAuthCallback.Token -> {
|
||||||
|
authViewModel.completeOAuthToken(callback.token)
|
||||||
|
oauthCallback.value = null
|
||||||
|
}
|
||||||
|
is OAuthCallback.Pending -> {
|
||||||
|
authViewModel.requireOAuthLink(callback.pendingToken, callback.email)
|
||||||
|
oauthCallback.value = null
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
TimeClockTheme {
|
TimeClockTheme {
|
||||||
TimeClockApp(
|
TimeClockApp(
|
||||||
authViewModel = authViewModel,
|
authViewModel = authViewModel,
|
||||||
@@ -58,7 +78,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
oauthCallback.value = intent.toOAuthCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent?.toOAuthCallback(): OAuthCallback? {
|
||||||
|
val data = this?.data ?: return null
|
||||||
|
if (data.scheme != "timeclock" || data.host != "oauth-callback") return null
|
||||||
|
val token = data.getQueryParameter("token")
|
||||||
|
if (!token.isNullOrBlank()) return OAuthCallback.Token(token)
|
||||||
|
val pending = data.getQueryParameter("pending")
|
||||||
|
if (!pending.isNullOrBlank()) return OAuthCallback.Pending(pending, data.getQueryParameter("email"))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "TimeClockStartup"
|
const val TAG = "TimeClockStartup"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class OAuthCallback {
|
||||||
|
data class Token(val token: String) : OAuthCallback()
|
||||||
|
data class Pending(val pendingToken: String, val email: String?) : OAuthCallback()
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,22 @@ data class LogoutResponse(
|
|||||||
val success: Boolean = true,
|
val success: Boolean = true,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OAuthLinkUrlRequest(
|
||||||
|
val platform: String = "android",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OAuthLinkUrlResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val url: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OAuthLinkExistingRequest(
|
||||||
|
val pendingToken: String,
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|||||||
@@ -69,6 +69,32 @@ class TimeClockApiClient(
|
|||||||
return decode(LogoutResponse.serializer(), raw.ifBlank { "{}" })
|
return decode(LogoutResponse.serializer(), raw.ifBlank { "{}" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun googleLoginUrl(): String = endpoint("auth/google?platform=android")
|
||||||
|
|
||||||
|
suspend fun createGoogleLinkUrl(): OAuthLinkUrlResponse {
|
||||||
|
val raw = execute(
|
||||||
|
authorized("auth/google/link-url")
|
||||||
|
.post(json.encodeToString(OAuthLinkUrlRequest.serializer(), OAuthLinkUrlRequest()).toRequestBody(JsonMedia))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return decode(OAuthLinkUrlResponse.serializer(), raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): LoginResponse {
|
||||||
|
val raw = execute(
|
||||||
|
Request.Builder()
|
||||||
|
.url(endpoint("auth/oauth/link-existing"))
|
||||||
|
.post(
|
||||||
|
json.encodeToString(
|
||||||
|
OAuthLinkExistingRequest.serializer(),
|
||||||
|
OAuthLinkExistingRequest(pendingToken, email, password),
|
||||||
|
).toRequestBody(JsonMedia),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return decode(LoginResponse.serializer(), raw)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getCurrentState(): CurrentStateResponse {
|
suspend fun getCurrentState(): CurrentStateResponse {
|
||||||
val raw = execute(authorized("time-entries/current-state").get().build())
|
val raw = execute(authorized("time-entries/current-state").get().build())
|
||||||
return decode(CurrentStateResponse.serializer(), raw)
|
return decode(CurrentStateResponse.serializer(), raw)
|
||||||
|
|||||||
@@ -63,6 +63,39 @@ class AuthRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun googleLoginUrl(): String = api.googleLoginUrl()
|
||||||
|
|
||||||
|
suspend fun createGoogleLinkUrl(): Result<String> =
|
||||||
|
try {
|
||||||
|
val res = api.createGoogleLinkUrl()
|
||||||
|
if (res.success && !res.url.isNullOrBlank()) {
|
||||||
|
Result.success(res.url)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(res.error ?: "Google-Verknüpfung konnte nicht gestartet werden"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun completeOAuthLogin(token: String): Result<UserProfile> {
|
||||||
|
tokenStore.saveToken(token)
|
||||||
|
val user = restoreSession()
|
||||||
|
return if (user != null) Result.success(user) else Result.failure(Exception("Google-Login konnte nicht abgeschlossen werden"))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun linkExistingOAuthAccount(pendingToken: String, email: String, password: String): Result<UserProfile> =
|
||||||
|
try {
|
||||||
|
val res = api.linkExistingOAuthAccount(pendingToken, email.trim(), password)
|
||||||
|
if (res.success && !res.token.isNullOrBlank() && res.user != null) {
|
||||||
|
tokenStore.saveToken(res.token)
|
||||||
|
Result.success(res.user.toProfile())
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(res.error ?: "Google-Verknüpfung fehlgeschlagen"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
try {
|
try {
|
||||||
api.postLogout()
|
api.postLogout()
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ class SettingsRepository(
|
|||||||
suspend fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) =
|
suspend fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) =
|
||||||
api.updateProfile(ProfileUpdateRequest(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType))
|
api.updateProfile(ProfileUpdateRequest(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType))
|
||||||
|
|
||||||
|
suspend fun createGoogleLinkUrl(): String {
|
||||||
|
val response = api.createGoogleLinkUrl()
|
||||||
|
if (!response.success || response.url.isNullOrBlank()) {
|
||||||
|
throw IllegalStateException(response.error ?: "Google-Verknüpfung konnte nicht gestartet werden")
|
||||||
|
}
|
||||||
|
return response.url
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
|
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
|
||||||
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
|
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.tsschulz.timeclock.ui
|
package de.tsschulz.timeclock.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -19,6 +21,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -77,12 +80,24 @@ fun TimeClockApp(
|
|||||||
val bookingState by bookingViewModel.uiState.collectAsStateWithLifecycle()
|
val bookingState by bookingViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val adminState by adminViewModel.uiState.collectAsStateWithLifecycle()
|
val adminState by adminViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val openUrl: (String) -> Unit = { url ->
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}
|
||||||
|
LaunchedEffect(settingsState.googleLinkUrl) {
|
||||||
|
settingsState.googleLinkUrl?.let {
|
||||||
|
openUrl(it)
|
||||||
|
settingsViewModel.consumeGoogleLinkUrl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!authState.isAuthenticated) {
|
if (!authState.isAuthenticated) {
|
||||||
LaunchedEffect(Unit) { timeViewModel.stop() }
|
LaunchedEffect(Unit) { timeViewModel.stop() }
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = authState,
|
state = authState,
|
||||||
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
|
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
|
||||||
|
onGoogleLogin = { openUrl(authViewModel.googleLoginUrl()) },
|
||||||
|
onLinkExistingGoogleAccount = { e, p -> authViewModel.linkExistingOAuthAccount(e, p) },
|
||||||
onRetryBootstrap = { authViewModel.retryBootstrap() },
|
onRetryBootstrap = { authViewModel.retryBootstrap() },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -93,6 +108,8 @@ fun TimeClockApp(
|
|||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = authState,
|
state = authState,
|
||||||
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
|
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
|
||||||
|
onGoogleLogin = { openUrl(authViewModel.googleLoginUrl()) },
|
||||||
|
onLinkExistingGoogleAccount = { e, p -> authViewModel.linkExistingOAuthAccount(e, p) },
|
||||||
onRetryBootstrap = { authViewModel.retryBootstrap() },
|
onRetryBootstrap = { authViewModel.retryBootstrap() },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -238,6 +255,7 @@ private fun DemoScreen(
|
|||||||
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
|
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
|
||||||
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
|
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
|
||||||
},
|
},
|
||||||
|
onLinkGoogle = { settingsViewModel.startGoogleLink() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppRoute.Password -> PasswordScreen(
|
AppRoute.Password -> PasswordScreen(
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ data class AuthUiState(
|
|||||||
val user: UserProfile? = null,
|
val user: UserProfile? = null,
|
||||||
val loginInProgress: Boolean = false,
|
val loginInProgress: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val googleLoginUrl: String? = null,
|
||||||
|
val oauthPendingToken: String? = null,
|
||||||
|
val oauthPendingEmail: String? = null,
|
||||||
/** Gespeicherter Token, aber `/auth/me` ist fehlgeschlagen (z. B. offline). */
|
/** Gespeicherter Token, aber `/auth/me` ist fehlgeschlagen (z. B. offline). */
|
||||||
val bootstrapWarn: String? = null,
|
val bootstrapWarn: String? = null,
|
||||||
)
|
)
|
||||||
@@ -35,6 +38,8 @@ class AuthViewModel(
|
|||||||
viewModelScope.launch { runBootstrap() }
|
viewModelScope.launch { runBootstrap() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun googleLoginUrl(): String = repository.googleLoginUrl()
|
||||||
|
|
||||||
private suspend fun runBootstrap() {
|
private suspend fun runBootstrap() {
|
||||||
val user = repository.restoreSession()
|
val user = repository.restoreSession()
|
||||||
val warn = if (user == null && repository.hasStoredToken()) {
|
val warn = if (user == null && repository.hasStoredToken()) {
|
||||||
@@ -88,6 +93,69 @@ class AuthViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun completeOAuthToken(token: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(loginInProgress = true, error = null) }
|
||||||
|
repository.completeOAuthLogin(token).fold(
|
||||||
|
onSuccess = { user ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
loginInProgress = false,
|
||||||
|
isAuthenticated = true,
|
||||||
|
user = user,
|
||||||
|
error = null,
|
||||||
|
oauthPendingToken = null,
|
||||||
|
oauthPendingEmail = null,
|
||||||
|
bootstrapWarn = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(loginInProgress = false, error = e.message ?: "Google-Login fehlgeschlagen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requireOAuthLink(pendingToken: String, email: String?) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
bootstrapping = false,
|
||||||
|
loginInProgress = false,
|
||||||
|
oauthPendingToken = pendingToken,
|
||||||
|
oauthPendingEmail = email,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun linkExistingOAuthAccount(email: String, password: String) {
|
||||||
|
val pendingToken = _uiState.value.oauthPendingToken
|
||||||
|
if (pendingToken.isNullOrBlank()) {
|
||||||
|
_uiState.update { it.copy(error = "Keine Google-Verknüpfung offen") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(loginInProgress = true, error = null) }
|
||||||
|
repository.linkExistingOAuthAccount(pendingToken, email, password).fold(
|
||||||
|
onSuccess = { user ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
loginInProgress = false,
|
||||||
|
isAuthenticated = true,
|
||||||
|
user = user,
|
||||||
|
oauthPendingToken = null,
|
||||||
|
oauthPendingEmail = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(loginInProgress = false, error = e.message ?: "Google-Verknüpfung fehlgeschlagen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun retryBootstrap() {
|
fun retryBootstrap() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(bootstrapping = true, bootstrapWarn = null) }
|
_uiState.update { it.copy(bootstrapping = true, bootstrapWarn = null) }
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ import de.tsschulz.timeclock.ui.theme.TcSpacing
|
|||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
state: AuthUiState,
|
state: AuthUiState,
|
||||||
onLogin: (email: String, password: String, action: String) -> Unit,
|
onLogin: (email: String, password: String, action: String) -> Unit,
|
||||||
|
onGoogleLogin: () -> Unit,
|
||||||
|
onLinkExistingGoogleAccount: (email: String, password: String) -> Unit,
|
||||||
onRetryBootstrap: () -> Unit,
|
onRetryBootstrap: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var email by rememberSaveable { mutableStateOf("") }
|
var email by rememberSaveable { mutableStateOf("") }
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var linkEmail by rememberSaveable { mutableStateOf("") }
|
||||||
|
var linkPassword by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -139,45 +143,79 @@ fun LoginScreen(
|
|||||||
|
|
||||||
state.error?.let { AuthErrorBanner(message = it) }
|
state.error?.let { AuthErrorBanner(message = it) }
|
||||||
|
|
||||||
AuthFormRow(
|
if (!state.oauthPendingToken.isNullOrBlank()) {
|
||||||
label = "E-Mail-Adresse",
|
if (linkEmail.isBlank()) linkEmail = state.oauthPendingEmail.orEmpty()
|
||||||
horizontal = useWideFormRows,
|
Text(
|
||||||
) {
|
text = "Für ${state.oauthPendingEmail ?: "dieses Google-Konto"} existiert bereits ein Account. Melden Sie sich mit dem bestehenden Account an, um ihn zu verknüpfen.",
|
||||||
TcTextField(
|
color = TcColors.Text,
|
||||||
label = "",
|
fontSize = 14.sp,
|
||||||
value = email,
|
|
||||||
onValueChange = { email = it },
|
|
||||||
placeholder = "Ihre E-Mail-Adresse eingeben",
|
|
||||||
showLabel = false,
|
|
||||||
)
|
)
|
||||||
}
|
AuthFormRow(label = "E-Mail-Adresse", horizontal = useWideFormRows) {
|
||||||
AuthFormRow(
|
TcTextField(label = "", value = linkEmail, onValueChange = { linkEmail = it }, showLabel = false)
|
||||||
label = "Passwort",
|
}
|
||||||
horizontal = useWideFormRows,
|
AuthFormRow(label = "Passwort", horizontal = useWideFormRows) {
|
||||||
) {
|
TcTextField(label = "", value = linkPassword, onValueChange = { linkPassword = it }, isPassword = true, showLabel = false)
|
||||||
TcTextField(
|
}
|
||||||
label = "",
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
value = password,
|
TcButton(
|
||||||
onValueChange = { password = it },
|
text = if (state.loginInProgress) "Wird verknüpft…" else "Bestehenden Account verknüpfen",
|
||||||
placeholder = "Ihr Passwort eingeben",
|
variant = ButtonVariant.Primary,
|
||||||
isPassword = true,
|
onClick = { onLinkExistingGoogleAccount(linkEmail, linkPassword) },
|
||||||
showLabel = false,
|
)
|
||||||
)
|
}
|
||||||
}
|
} else {
|
||||||
|
AuthFormRow(
|
||||||
|
label = "E-Mail-Adresse",
|
||||||
|
horizontal = useWideFormRows,
|
||||||
|
) {
|
||||||
|
TcTextField(
|
||||||
|
label = "",
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
placeholder = "Ihre E-Mail-Adresse eingeben",
|
||||||
|
showLabel = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthFormRow(
|
||||||
|
label = "Passwort",
|
||||||
|
horizontal = useWideFormRows,
|
||||||
|
) {
|
||||||
|
TcTextField(
|
||||||
|
label = "",
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
placeholder = "Ihr Passwort eingeben",
|
||||||
|
isPassword = true,
|
||||||
|
showLabel = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TcButton(
|
TcButton(
|
||||||
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
|
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
|
||||||
variant = ButtonVariant.Primary,
|
variant = ButtonVariant.Primary,
|
||||||
onClick = { onLogin(email, password, "0") },
|
onClick = { onLogin(email, password, "0") },
|
||||||
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
|
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OAuthDivider()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
|
TcButton(
|
||||||
|
text = "Mit Google anmelden",
|
||||||
|
variant = ButtonVariant.Default,
|
||||||
|
onClick = onGoogleLogin,
|
||||||
|
modifier = Modifier.defaultMinSize(minWidth = 220.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +226,19 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OAuthDivider() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
|
||||||
|
) {
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f), color = TcColors.Border)
|
||||||
|
Text("oder", color = TcColors.DividerText, fontSize = 14.sp)
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f), color = TcColors.Border)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoginTopBar() {
|
private fun LoginTopBar() {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ fun ProfileScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
onSave: (String, String?, Int, Double, Int) -> Unit,
|
onSave: (String, String?, Int, Double, Int) -> Unit,
|
||||||
|
onLinkGoogle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val profile = state.profile
|
val profile = state.profile
|
||||||
var fullName by rememberSaveable { mutableStateOf("") }
|
var fullName by rememberSaveable { mutableStateOf("") }
|
||||||
@@ -80,6 +81,8 @@ fun ProfileScreen(
|
|||||||
TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5")
|
TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5")
|
||||||
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
|
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
|
||||||
TitleTypeDropdown(preferredTitleType, { preferredTitleType = it })
|
TitleTypeDropdown(preferredTitleType, { preferredTitleType = it })
|
||||||
|
FieldLabel("Google-Anmeldung")
|
||||||
|
TcButton("Mit Google-Konto verknüpfen", variant = ButtonVariant.Default, onClick = onLinkGoogle)
|
||||||
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
|
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
|
||||||
onSave(
|
onSave(
|
||||||
fullName,
|
fullName,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ data class SettingsUiState(
|
|||||||
val timewishes: List<TimewishDto> = emptyList(),
|
val timewishes: List<TimewishDto> = emptyList(),
|
||||||
val invites: List<InvitationDto> = emptyList(),
|
val invites: List<InvitationDto> = emptyList(),
|
||||||
val watchers: List<WatcherDto> = emptyList(),
|
val watchers: List<WatcherDto> = emptyList(),
|
||||||
|
val googleLinkUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
@@ -48,6 +49,19 @@ class SettingsViewModel(
|
|||||||
loadProfile()
|
loadProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startGoogleLink() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(loading = true, error = null, googleLinkUrl = null) }
|
||||||
|
runCatching { repository.createGoogleLinkUrl() }
|
||||||
|
.onSuccess { url -> _uiState.update { it.copy(loading = false, googleLinkUrl = url) } }
|
||||||
|
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Google-Verknüpfung konnte nicht gestartet werden") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeGoogleLinkUrl() {
|
||||||
|
_uiState.update { it.copy(googleLinkUrl = null) }
|
||||||
|
}
|
||||||
|
|
||||||
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
|
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
|
||||||
repository.changePassword(oldPassword, newPassword, confirmPassword)
|
repository.changePassword(oldPassword, newPassword, confirmPassword)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user