Compare commits

...

5 Commits

Author SHA1 Message Date
hyung-hwan a28b6cfa80 enhanced the logout process 2026-06-18 21:27:38 +09:00
hyung-hwan 37cc8052a9 fixed the sso signout issue 2026-06-18 13:07:26 +09:00
hyung-hwan ef6b857ac6 added more charts 2026-06-18 12:49:50 +09:00
hyung-hwan 36b09b9853 more charts 2026-06-18 11:56:57 +09:00
hyung-hwan 7021c55e1b added more charts for a board 2026-06-18 11:47:01 +09:00
16 changed files with 1016 additions and 115 deletions
+7 -6
View File
@@ -46,6 +46,7 @@ func scanAuthProvider(row interface {
&item.OIDCGroupsClaim, &item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr, &item.OIDCAdmissionExpr,
&item.OIDCEndSessionURL, &item.OIDCEndSessionURL,
&item.OIDCPostLogoutRedirect,
&item.GroupSyncMode, &item.GroupSyncMode,
&defaultUserGroupID, &defaultUserGroupID,
&item.UserCount, &item.UserCount,
@@ -65,7 +66,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify, p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url, p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify, p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.group_sync_mode, p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.oidc_post_logout_redirect, p.group_sync_mode,
COALESCE(ug.public_id, ''), COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id), (SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at` p.created_at, p.updated_at`
@@ -307,16 +308,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify, ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url, oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify, oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, group_sync_mode, oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, oidc_post_logout_redirect, group_sync_mode,
default_user_group_id, default_user_group_id,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled, item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter, item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode, item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt, item.CreatedAt, item.UpdatedAt,
) )
@@ -367,7 +368,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify = ?, ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?, oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?, oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, group_sync_mode = ?, oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, oidc_post_logout_redirect = ?, group_sync_mode = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ? updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
@@ -376,7 +377,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode, item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt, item.UpdatedAt,
strings.TrimSpace(item.ID), strings.TrimSpace(item.ID),
+11 -4
View File
@@ -647,14 +647,21 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
return user, nil return user, nil
} }
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool) error { func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string) error {
var err error var err error
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified) _, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?)`, VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified) mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified, oidcIDToken)
return err return err
} }
func (s *Store) GetSessionOIDCIDToken(token string) (string, error) {
var idToken string
var err error
err = s.QueryRow(`SELECT oidc_id_token FROM sessions WHERE token = ?`, token).Scan(&idToken)
return idToken, err
}
func (s *Store) SetSessionTOTPVerified(token string) error { func (s *Store) SetSessionTOTPVerified(token string) error {
var err error var err error
_, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token) _, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token)
+87 -3
View File
@@ -11,6 +11,7 @@ import "fmt"
import "io" import "io"
import "mime/multipart" import "mime/multipart"
import "net/http" import "net/http"
import "net/url"
import "os" import "os"
import "path/filepath" import "path/filepath"
import "sort" import "sort"
@@ -537,7 +538,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials")
} }
func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool) { func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool, oidcIDToken string) {
var token string var token string
var err error var err error
var expires time.Time var expires time.Time
@@ -548,7 +549,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
expires = auth.SessionExpiry(api.Cfg) expires = auth.SessionExpiry(api.Cfg)
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC()) _ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = api.store(r).CreateSession(user.ID, token, expires, totpVerified) _ = api.store(r).CreateSession(user.ID, token, expires, totpVerified, oidcIDToken)
cookie = &http.Cookie{ cookie = &http.Cookie{
Name: api.sessionCookieName(), Name: api.sessionCookieName(),
Value: token, Value: token,
@@ -562,6 +563,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var cookie *http.Cookie var cookie *http.Cookie
var endSessionURL string var endSessionURL string
var oidcIDToken string
var err error var err error
cookie, err = r.Cookie(api.sessionCookieName()) cookie, err = r.Cookie(api.sessionCookieName())
@@ -572,9 +574,14 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" { if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" {
var provider models.AuthProvider var provider models.AuthProvider
var provErr error var provErr error
var tokenErr error
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID) provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
if provErr == nil { if provErr == nil {
endSessionURL = strings.TrimSpace(provider.OIDCEndSessionURL) oidcIDToken, tokenErr = api.store(r).GetSessionOIDCIDToken(cookie.Value)
if tokenErr != nil {
oidcIDToken = ""
}
endSessionURL = api.buildOIDCEndSessionURL(r, provider, oidcIDToken)
} }
} }
_ = api.store(r).DeleteSession(cookie.Value) _ = api.store(r).DeleteSession(cookie.Value)
@@ -594,6 +601,68 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (api *API) buildOIDCEndSessionURL(r *http.Request, provider models.AuthProvider, idToken string) string {
var raw string
var parsed *url.URL
var values url.Values
var redirectURL string
var err error
raw = strings.TrimSpace(provider.OIDCEndSessionURL)
if raw == "" {
return ""
}
parsed, err = url.Parse(raw)
if err != nil {
return raw
}
values = parsed.Query()
if strings.TrimSpace(idToken) != "" && strings.TrimSpace(values.Get("id_token_hint")) == "" {
values.Set("id_token_hint", strings.TrimSpace(idToken))
}
if provider.OIDCPostLogoutRedirect {
redirectURL = api.postLogoutRedirectURL(r)
if redirectURL != "" && strings.TrimSpace(values.Get("post_logout_redirect_uri")) == "" {
values.Set("post_logout_redirect_uri", redirectURL)
}
}
parsed.RawQuery = values.Encode()
return parsed.String()
}
func (api *API) postLogoutRedirectURL(r *http.Request) string {
var base string
var host string
var scheme string
base = strings.TrimRight(strings.TrimSpace(api.Cfg.PublicBaseURL), "/")
if base != "" {
return base + "/login"
}
host = strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if host != "" {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
scheme = strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if scheme != "" {
scheme = strings.TrimSpace(strings.Split(scheme, ",")[0])
}
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
return scheme + "://" + host + "/login"
}
func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var user models.User var user models.User
var permissions []string var permissions []string
@@ -616,9 +685,24 @@ func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string)
if ok { if ok {
response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified
} }
response.LogoutRequiresConfirmation = api.logoutRequiresConfirmation(r, user)
WriteJSON(w, http.StatusOK, response) WriteJSON(w, http.StatusOK, response)
} }
func (api *API) logoutRequiresConfirmation(r *http.Request, user models.User) bool {
var provider models.AuthProvider
var err error
if strings.TrimSpace(user.AuthProviderID) == "" {
return false
}
provider, err = api.store(r).GetAuthProvider(user.AuthProviderID)
if err != nil {
return false
}
return provider.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(provider.OIDCEndSessionURL) != ""
}
func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User var ctxUser models.User
var ok bool var ok bool
+2 -2
View File
@@ -157,7 +157,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider) user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed provider=%s err=%v", provider.Name, err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc user mapping failed") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, fmt.Sprintf("oidc user mapping failed - %s", err.Error()))
return return
} }
required, err = api.store(r).UserRequiresTOTP(user.ID) required, err = api.store(r).UserRequiresTOTP(user.ID)
@@ -173,7 +173,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user.TOTPEffectiveRequired = required user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required sessionVerified = !required
api.issueSession(w, r, user, sessionVerified) api.issueSession(w, r, user, sessionVerified, token.IDToken)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name)
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
@@ -25,6 +25,7 @@ type meResponse struct {
TOTPEffectiveRequired bool `json:"totp_effective_required"` TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"` TOTPSetupRequired bool `json:"totp_setup_required"`
TOTPVerifyRequired bool `json:"totp_verify_required"` TOTPVerifyRequired bool `json:"totp_verify_required"`
LogoutRequiresConfirmation bool `json:"logout_requires_confirmation"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
AvatarUpdatedAt int64 `json:"avatar_updated_at"` AvatarUpdatedAt int64 `json:"avatar_updated_at"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
+1 -1
View File
@@ -61,7 +61,7 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
if required && !user.TOTPEnabled { if required && !user.TOTPEnabled {
sessionVerified = false sessionVerified = false
} }
api.issueSession(w, r, user, sessionVerified) api.issueSession(w, r, user, sessionVerified, "")
WriteJSON(w, http.StatusOK, user) WriteJSON(w, http.StatusOK, user)
} }
+1
View File
@@ -50,6 +50,7 @@ type AuthProvider struct {
OIDCGroupsClaim string `json:"oidc_groups_claim"` OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"` OIDCAdmissionExpr string `json:"oidc_admission_expr"`
OIDCEndSessionURL string `json:"oidc_end_session_url"` OIDCEndSessionURL string `json:"oidc_end_session_url"`
OIDCPostLogoutRedirect bool `json:"oidc_post_logout_redirect"`
GroupSyncMode string `json:"group_sync_mode"` GroupSyncMode string `json:"group_sync_mode"`
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"` GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
DefaultUserGroupID string `json:"default_user_group_id"` DefaultUserGroupID string `json:"default_user_group_id"`
@@ -0,0 +1 @@
ALTER TABLE auth_providers ADD COLUMN oidc_post_logout_redirect INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN oidc_id_token TEXT NOT NULL DEFAULT '';
+2
View File
@@ -10,6 +10,7 @@ export interface User {
totp_effective_required?: boolean totp_effective_required?: boolean
totp_setup_required?: boolean totp_setup_required?: boolean
totp_verify_required?: boolean totp_verify_required?: boolean
logout_requires_confirmation?: boolean
avatar_url?: string avatar_url?: string
avatar_updated_at?: number avatar_updated_at?: number
disabled?: boolean disabled?: boolean
@@ -467,6 +468,7 @@ export interface AuthProvider {
oidc_groups_claim: string oidc_groups_claim: string
oidc_admission_expr: string oidc_admission_expr: string
oidc_end_session_url: string oidc_end_session_url: string
oidc_post_logout_redirect: boolean
group_sync_mode: 'off' | 'first_login' | 'sync' group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings?: AuthProviderGroupMapping[] group_mappings?: AuthProviderGroupMapping[]
default_user_group_id: string default_user_group_id: string
+49 -3
View File
@@ -4,6 +4,9 @@ import {
Box, Box,
Button, Button,
Container, Container,
DialogActions,
DialogContent,
DialogTitle,
Divider, Divider,
Drawer, Drawer,
IconButton, IconButton,
@@ -18,6 +21,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom' import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { api, User } from '../api' import { api, User } from '../api'
import ModalDialog from '../components/ModalDialog'
import { useBrand } from '../branding' import { useBrand } from '../branding'
import MenuIcon from '@mui/icons-material/Menu' import MenuIcon from '@mui/icons-material/Menu'
import DashboardIcon from '@mui/icons-material/Dashboard' import DashboardIcon from '@mui/icons-material/Dashboard'
@@ -70,6 +74,8 @@ export default function Layout() {
const [drawerOpen, setDrawerOpen] = useState(true) const [drawerOpen, setDrawerOpen] = useState(true)
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null) const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null) const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [logoutBusy, setLogoutBusy] = useState(false)
const themeMode = useContext(ThemeModeContext) const themeMode = useContext(ThemeModeContext)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -128,10 +134,28 @@ export default function Layout() {
] ]
}, [user]) }, [user])
const handleLogout = async () => { const openLogoutDialog = () => {
closeAccountMenu() closeAccountMenu()
const result = await api.logout() if (!user?.logout_requires_confirmation) {
setLogoutBusy(true)
handleLogout().catch(() => {
setLogoutBusy(false)
})
return
}
setLogoutDialogOpen(true)
}
const closeLogoutDialog = () => {
if (logoutBusy) return
setLogoutDialogOpen(false)
}
const handleLogout = async () => {
const result: { oidc_end_session_url?: string } | null = await api.logout()
setUser(null) setUser(null)
setLogoutDialogOpen(false)
setLogoutBusy(false)
if (result?.oidc_end_session_url) { if (result?.oidc_end_session_url) {
window.location.href = result.oidc_end_session_url window.location.href = result.oidc_end_session_url
} else { } else {
@@ -139,6 +163,13 @@ export default function Layout() {
} }
} }
const confirmLogout = () => {
setLogoutBusy(true)
handleLogout().catch(() => {
setLogoutBusy(false)
})
}
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => { const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchor(event.currentTarget) setAccountMenuAnchor(event.currentTarget)
} }
@@ -247,7 +278,7 @@ export default function Layout() {
<MenuItem disabled>{user.username}</MenuItem> <MenuItem disabled>{user.username}</MenuItem>
<Divider /> <Divider />
<MenuItem onClick={openAccountPage}>Account</MenuItem> <MenuItem onClick={openAccountPage}>Account</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem> <MenuItem onClick={openLogoutDialog}>Sign out</MenuItem>
</Menu> </Menu>
</> </>
) : null} ) : null}
@@ -330,6 +361,21 @@ export default function Layout() {
<Outlet context={{ user, setUser }} /> <Outlet context={{ user, setUser }} />
</Container> </Container>
</Box> </Box>
<ModalDialog open={logoutDialogOpen} onClose={closeLogoutDialog} maxWidth="xs" fullWidth>
<DialogTitle>Sign out?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
This will sign you out of {brand.site_name}. If your account uses OIDC, it may also sign you out of your identity provider session.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={confirmLogout} disabled={logoutBusy} variant="contained" color="primary">
{logoutBusy ? 'Signing out...' : 'Sign out'}
</Button>
<Button onClick={closeLogoutDialog} disabled={logoutBusy}>Cancel</Button>
</DialogActions>
</ModalDialog>
</Box> </Box>
) )
} }
@@ -94,6 +94,7 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
</Box> </Box>
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} /> <TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
<TextField label="End Session URL" value={item.oidc_end_session_url || '-'} InputProps={{ readOnly: true }} /> <TextField label="End Session URL" value={item.oidc_end_session_url || '-'} InputProps={{ readOnly: true }} />
<TextField label="Post Logout Redirect" value={yesNo(item.oidc_post_logout_redirect)} InputProps={{ readOnly: true }} />
</> </>
) : null} ) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} /> <TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
@@ -40,6 +40,7 @@ export type AuthProviderFormState = {
oidc_groups_claim: string oidc_groups_claim: string
oidc_admission_expr: string oidc_admission_expr: string
oidc_end_session_url: string oidc_end_session_url: string
oidc_post_logout_redirect: boolean
group_sync_mode: 'off' | 'first_login' | 'sync' group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings: AuthProviderGroupMapping[] group_mappings: AuthProviderGroupMapping[]
default_user_group_id: string default_user_group_id: string
@@ -80,6 +81,7 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
oidc_groups_claim: 'groups', oidc_groups_claim: 'groups',
oidc_admission_expr: '', oidc_admission_expr: '',
oidc_end_session_url: '', oidc_end_session_url: '',
oidc_post_logout_redirect: false,
group_sync_mode: 'off', group_sync_mode: 'off',
group_mappings: [], group_mappings: [],
default_user_group_id: '' default_user_group_id: ''
@@ -108,6 +110,7 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
oidc_groups_claim: item.oidc_groups_claim || 'groups', oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '', oidc_admission_expr: item.oidc_admission_expr || '',
oidc_end_session_url: item.oidc_end_session_url || '', oidc_end_session_url: item.oidc_end_session_url || '',
oidc_post_logout_redirect: item.oidc_post_logout_redirect,
group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off', group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off',
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({ group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
claim_value: mapping.claim_value || '', claim_value: mapping.claim_value || '',
@@ -369,6 +372,18 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_end_session_url: event.target.value }))} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_end_session_url: event.target.value }))}
helperText="RP-initiated logout endpoint (end_session_endpoint). When set, users are redirected here on logout to also clear their SSO session." helperText="RP-initiated logout endpoint (end_session_endpoint). When set, users are redirected here on logout to also clear their SSO session."
/> />
<FormControlLabel
control={
<Checkbox
checked={props.form.oidc_post_logout_redirect}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_post_logout_redirect: event.target.checked }))}
/>
}
label="Append post-logout redirect URI"
/>
<Typography variant="caption" color="text.secondary">
When enabled, logout appends post_logout_redirect_uri back to the login page. The ID token is also sent as id_token_hint when available.
</Typography>
</Box> </Box>
</> </>
) : null} ) : null}
+3 -1
View File
@@ -88,7 +88,9 @@ export default function BoardCardContent(props: BoardCardContentProps) {
))} ))}
{descriptionText ? ( {descriptionText ? (
<Tooltip title={descriptionText} arrow> <Tooltip title={descriptionText} arrow>
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox> <span>
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox>
</span>
</Tooltip> </Tooltip>
) : null} ) : null}
{checklistTotal > 0 ? ( {checklistTotal > 0 ? (
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -33,7 +33,7 @@ export default function LoginPage() {
await api.login(username, password, otpRequired ? otpCode : '') await api.login(username, password, otpRequired ? otpCode : '')
navigate('/') navigate('/')
} catch (err) { } catch (err) {
message = err instanceof Error ? err.message : 'Login failed' message = err instanceof Error ? err.message : 'Sign in failed'
if (message === 'totp_required') { if (message === 'totp_required') {
setOTPRequired(true) setOTPRequired(true)
setOTPCode('') setOTPCode('')
@@ -69,7 +69,7 @@ export default function LoginPage() {
</Typography> </Typography>
)} )}
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}> <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
{otpRequired ? 'Verify' : 'Login'} {otpRequired ? 'Verify' : 'Sign in'}
</Button> </Button>
{oidcProviders.map((p) => ( {oidcProviders.map((p) => (
<Button <Button
@@ -82,7 +82,7 @@ export default function LoginPage() {
window.location.assign(`/api/auth/oidc/${p.id}/login`) window.location.assign(`/api/auth/oidc/${p.id}/login`)
}} }}
> >
Login with {p.name} Sign in with {p.name}
</Button> </Button>
))} ))}
</Box> </Box>