Merge pull request #26 from cadtoolbox/copilot/cleanup-advanced-authentication

Cleanup Advanced Authentication: external URL for emails, dialog fixes, i18n
This commit is contained in:
Thomas Rambach
2026-02-09 18:30:36 -05:00
committed by GitHub
13 changed files with 314 additions and 219 deletions
+3 -6
View File
@@ -41,6 +41,7 @@ from backend.app.services.email_service import (
save_smtp_settings,
send_email,
)
from backend.app.api.routes.settings import get_external_login_url
def _user_to_response(user: User) -> UserResponse:
@@ -524,7 +525,6 @@ async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Request password reset via email (advanced auth only)."""
import logging
import os
logger = logging.getLogger(__name__)
@@ -556,8 +556,7 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
user.password_hash = get_password_hash(new_password)
await db.commit()
# Get login URL from environment or use default
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
login_url = await get_external_login_url(db)
# Send password reset email
subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
@@ -581,7 +580,6 @@ async def reset_user_password(
):
"""Reset a user's password and send them an email (admin only, advanced auth only)."""
import logging
import os
logger = logging.getLogger(__name__)
@@ -632,8 +630,7 @@ async def reset_user_password(
user.password_hash = get_password_hash(new_password)
await db.commit()
# Get login URL from environment or use default
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
login_url = await get_external_login_url(db)
# Send password reset email
subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
+21
View File
@@ -32,6 +32,27 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
return setting.value if setting else None
async def get_external_login_url(db: AsyncSession) -> str:
"""Get the external URL for the login page.
Uses external_url from settings if available, otherwise falls back to APP_URL env var.
Args:
db: Database session
Returns:
Full URL to the login page
"""
import os
external_url = await get_setting(db, "external_url")
if external_url:
external_url = external_url.rstrip("/")
else:
external_url = os.environ.get("APP_URL", "http://localhost:5173")
return external_url + "/login"
async def set_setting(db: AsyncSession, key: str, value: str) -> None:
"""Set a single setting value."""
from sqlalchemy import func
+2 -2
View File
@@ -24,6 +24,7 @@ from backend.app.services.email_service import (
get_smtp_settings,
send_email,
)
from backend.app.api.routes.settings import get_external_login_url
router = APIRouter(prefix="/users", tags=["users"])
@@ -70,7 +71,6 @@ async def create_user(
- Admin cannot set or see the password
"""
import logging
import os
logger = logging.getLogger(__name__)
@@ -152,7 +152,7 @@ async def create_user(
try:
smtp_settings = await get_smtp_settings(db)
if smtp_settings:
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
login_url = await get_external_login_url(db)
subject, text_body, html_body = create_welcome_email(new_user.username, password, login_url)
send_email(smtp_settings, new_user.email, subject, text_body, html_body)
logger.info(f"Welcome email sent to {new_user.email}")
@@ -84,7 +84,7 @@ export function CreateUserAdvancedAuthModal({
{/* Username Field */}
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.username')}
{t('users.form.username')} <span className="text-red-400">*</span>
</label>
<input
type="text"
@@ -93,6 +93,7 @@ export function CreateUserAdvancedAuthModal({
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('users.form.usernamePlaceholder')}
autoComplete="username"
required
/>
</div>
+3
View File
@@ -1021,6 +1021,7 @@ export default {
saving: 'Wird gespeichert...',
advancedAuth: 'Erweiterte Authentifizierung',
advancedAuthEnabled: 'Erweiterte Authentifizierung ist aktiviert',
advancedAuthEnabledDesc: 'E-Mail-basierte Benutzerverwaltungsfunktionen sind aktiv. Neue Benutzer erhalten automatisch generierte Passwörter per E-Mail und können ihr Passwort über die Passwort vergessen Funktion zurücksetzen.',
advancedAuthDisabled: 'Erweiterte Authentifizierung ist deaktiviert',
advancedAuthDisabledDesc: 'Aktivieren Sie die erweiterte Authentifizierung, um E-Mail-basierte Funktionen für die Benutzerverwaltung zu aktivieren.',
enable: 'Aktivieren',
@@ -1623,6 +1624,7 @@ export default {
creating: 'Erstellen...',
saving: 'Speichern...',
saveChanges: 'Änderungen speichern',
advancedAuthSubtitle: 'mit erweiterter Authentifizierung',
},
form: {
username: 'Benutzername',
@@ -1639,6 +1641,7 @@ export default {
groups: 'Gruppen',
optional: 'optional',
autoGeneratedPassword: 'Ein sicheres Passwort wird automatisch generiert und per E-Mail an den Benutzer gesendet.',
passwordManagedByAdvancedAuth: 'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie "Passwort zurücksetzen", um ein neues Passwort per E-Mail an den Benutzer zu senden.',
},
deleteModal: {
title: 'Benutzer löschen',
+3
View File
@@ -1021,6 +1021,7 @@ export default {
saving: 'Saving...',
advancedAuth: 'Advanced Authentication',
advancedAuthEnabled: 'Advanced Authentication is enabled',
advancedAuthEnabledDesc: 'Email-based user management features are active. New users will receive auto-generated passwords via email, and users can reset their passwords through the forgot password feature.',
advancedAuthDisabled: 'Advanced Authentication is disabled',
advancedAuthDisabledDesc: 'Enable advanced authentication to activate email-based features for user management.',
enable: 'Enable',
@@ -1623,6 +1624,7 @@ export default {
creating: 'Creating...',
saving: 'Saving...',
saveChanges: 'Save Changes',
advancedAuthSubtitle: 'with Advanced Authentication',
},
form: {
username: 'Username',
@@ -1639,6 +1641,7 @@ export default {
groups: 'Groups',
optional: 'optional',
autoGeneratedPassword: 'A secure password will be automatically generated and emailed to the user.',
passwordManagedByAdvancedAuth: 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.',
},
deleteModal: {
title: 'Delete User',
+3
View File
@@ -1173,6 +1173,7 @@ export default {
saving: '保存中...',
advancedAuth: '高度な認証',
advancedAuthEnabled: '高度な認証が有効です',
advancedAuthEnabledDesc: 'メールベースのユーザー管理機能が有効になっています。新規ユーザーには自動生成されたパスワードがメールで送信され、ユーザーはパスワード忘れ機能でパスワードをリセットできます。',
advancedAuthDisabled: '高度な認証が無効です',
advancedAuthDisabledDesc: '高度な認証を有効にして、ユーザー管理のメールベース機能を有効化してください。',
enable: '有効にする',
@@ -2172,6 +2173,7 @@ export default {
groups: 'グループ',
optional: 'オプション',
autoGeneratedPassword: '安全なパスワードが自動的に生成され、ユーザーにメールで送信されます。',
passwordManagedByAdvancedAuth: 'パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、メールで新しいパスワードをユーザーに送信してください。',
},
modal: {
createUser: 'ユーザーを作成',
@@ -2180,6 +2182,7 @@ export default {
creating: '作成中...',
saving: '保存中...',
saveChanges: '変更を保存',
advancedAuthSubtitle: '高度な認証を使用',
},
deleteModal: {
title: 'ユーザーを削除',
+93 -85
View File
@@ -7,6 +7,8 @@ import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
import { X, Mail } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardHeader, CardContent } from '../components/Card';
import { Button } from '../components/Button';
export function LoginPage() {
const navigate = useNavigate();
@@ -151,97 +153,103 @@ export function LoginPage() {
{/* Forgot Password Modal */}
{showForgotPassword && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => setShowForgotPassword(false)}
>
<div
className="w-full max-w-md bg-bambu-card rounded-xl border border-bambu-dark-tertiary shadow-lg p-6"
onClick={(e) => e.stopPropagation()}
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Mail className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
</div>
<button
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
className="p-1 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{advancedAuthStatus?.advanced_auth_enabled ? (
<form onSubmit={handleForgotPassword} className="space-y-4">
<p className="text-bambu-gray text-sm">
{t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
</p>
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
{t('login.emailAddress') || 'Email Address'}
</label>
<input
id="forgot-email"
type="email"
required
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
/>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
className="flex-1 py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
{t('login.cancel') || 'Cancel'}
</button>
<button
type="submit"
disabled={forgotPasswordMutation.isPending}
className="flex-1 py-2 px-4 bg-bambu-green hover:bg-bambu-green-light text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{forgotPasswordMutation.isPending
? (t('login.sending') || 'Sending...')
: (t('login.sendResetEmail') || 'Send Reset Email')}
</button>
</div>
</form>
) : (
<div className="space-y-4">
<p className="text-bambu-gray">
{t('login.forgotPasswordMessage')}
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>{t('login.resetStep1')}</li>
<li>{t('login.resetStep2')}</li>
<li>{t('login.resetStep3')}</li>
<li>{t('login.resetStep4')}</li>
</ol>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
>
{t('login.gotIt')}
</button>
<X className="w-5 h-5" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{advancedAuthStatus?.advanced_auth_enabled ? (
<form onSubmit={handleForgotPassword} className="space-y-4">
<p className="text-bambu-gray text-sm">
{t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
</p>
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
{t('login.emailAddress') || 'Email Address'}
</label>
<input
id="forgot-email"
type="email"
required
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
>
{t('login.cancel') || 'Cancel'}
</Button>
<Button
type="submit"
className="flex-1"
disabled={forgotPasswordMutation.isPending}
>
{forgotPasswordMutation.isPending
? (t('login.sending') || 'Sending...')
: (t('login.sendResetEmail') || 'Send Reset Email')}
</Button>
</div>
</form>
) : (
<div className="space-y-4">
<p className="text-bambu-gray">
{t('login.forgotPasswordMessage')}
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>{t('login.resetStep1')}</li>
<li>{t('login.resetStep2')}</li>
<li>{t('login.resetStep3')}</li>
<li>{t('login.resetStep4')}</li>
</ol>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => setShowForgotPassword(false)}
>
{t('login.gotIt')}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
+91 -32
View File
@@ -3426,6 +3426,25 @@ export function SettingsPage() {
</CardContent>
</Card>
{/* Advanced Authentication Notice Box */}
{advancedAuthStatus?.advanced_auth_enabled && (
<Card className="border-blue-500/30 bg-blue-500/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0">
<Mail className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="text-white font-medium">{t('settings.email.advancedAuthEnabled')}</h3>
<p className="text-sm text-bambu-gray mt-1">
{t('settings.email.advancedAuthEnabledDesc')}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{authEnabled && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column: Current User + User List */}
@@ -3860,8 +3879,11 @@ export function SettingsPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Username Field */}
<div>
<label className="block text-sm font-medium text-white mb-2">{t('settings.username')}</label>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.username')} {advancedAuthStatus?.advanced_auth_enabled && <span className="text-red-400">*</span>}
</label>
<input
type="text"
value={userFormData.username}
@@ -3871,43 +3893,75 @@ export function SettingsPage() {
autoComplete="username"
/>
</div>
{/* Email Field */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
{t('users.form.email') || 'Email'} {advancedAuthStatus?.advanced_auth_enabled ? <span className="text-red-400">*</span> : <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>}
</label>
<input
type="password"
value={userFormData.password}
onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value, confirmPassword: '' })}
type="email"
value={userFormData.email}
onChange={(e) => setUserFormData({ ...userFormData, email: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('settings.enterNewPassword')}
autoComplete="new-password"
minLength={6}
placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
required={advancedAuthStatus?.advanced_auth_enabled}
/>
</div>
{userFormData.password && (
<div>
<label className="block text-sm font-medium text-white mb-2">{t('settings.confirmPassword')}</label>
<input
type="password"
value={userFormData.confirmPassword}
onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder={t('settings.confirmNewPassword')}
autoComplete="new-password"
minLength={6}
/>
{userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">{t('settings.passwordsDoNotMatch')}</p>
{/* Password Fields - only show when Advanced Auth is disabled */}
{!advancedAuthStatus?.advanced_auth_enabled && (
<>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password') || 'Password'} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep') || 'leave blank to keep current'})</span>
</label>
<input
type="password"
value={userFormData.password}
onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value, confirmPassword: '' })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('settings.enterNewPassword')}
autoComplete="new-password"
minLength={6}
/>
</div>
{userFormData.password && (
<div>
<label className="block text-sm font-medium text-white mb-2">{t('settings.confirmPassword')}</label>
<input
type="password"
value={userFormData.confirmPassword}
onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder={t('settings.confirmNewPassword')}
autoComplete="new-password"
minLength={6}
/>
{userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">{t('settings.passwordsDoNotMatch')}</p>
)}
</div>
)}
</>
)}
{/* Info box about auto-generated password when Advanced Auth is enabled */}
{advancedAuthStatus?.advanced_auth_enabled && (
<div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3">
<p className="text-sm text-bambu-gray">
{t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.'}
</p>
</div>
)}
{/* Groups Field */}
<div>
<label className="block text-sm font-medium text-white mb-2">Groups</label>
<label className="block text-sm font-medium text-white mb-2">{t('users.form.groups') || 'Groups'}</label>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
{groupsData.map(group => (
<label
@@ -3922,7 +3976,7 @@ export function SettingsPage() {
/>
<span className="text-sm text-white">{group.name}</span>
{group.is_system && (
<span className="text-xs text-yellow-400">(System)</span>
<span className="text-xs text-yellow-400">({t('users.system') || 'System'})</span>
)}
</label>
))}
@@ -3938,21 +3992,26 @@ export function SettingsPage() {
setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
Cancel
{t('users.modal.cancel') || 'Cancel'}
</Button>
<Button
onClick={() => handleUpdateUser(editingUserId)}
disabled={updateUserMutation.isPending || !userFormData.username || !!(userFormData.password && (userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6))}
disabled={
updateUserMutation.isPending ||
!userFormData.username ||
(advancedAuthStatus?.advanced_auth_enabled && !userFormData.email) ||
Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6))
}
>
{updateUserMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
{t('users.modal.saving') || 'Saving...'}
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
{t('users.modal.saveChanges') || 'Save Changes'}
</>
)}
</Button>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-C0uTHy_F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbCAeYz5.css">
<script type="module" crossorigin src="/assets/index-C4SeuzlU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-RuMjImbZ.css">
</head>
<body>
<div id="root"></div>