package api import ( "agent-desk/internal/builders" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/loginaudience" "agent-desk/internal/services" "log/slog" "net/http" "net/url" "strings" "github.com/gin-gonic/gin" ) func Login(ctx *gin.Context) { cfg := config.Current() if !cfg.Auth.IsPasswordLoginEnabled() { httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled")) return } req := request.LoginRequest{} if err := params.ReadJSON(ctx, &req); err != nil { httpx.WriteJSON(ctx, err) return } ret, err := services.AuthService.Login(req, cfg.Auth, ctx.ClientIP(), ctx.GetHeader("User-Agent")) if err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, ret) } func PublicConfig(ctx *gin.Context) { cfg := config.Current() // Saved branding wins over configuration, so an operator can rename a // deployment from the dashboard without touching the file it is running from. branding := services.ResolveBranding() httpx.WriteJSON(ctx, &response.PublicConfigResponse{ Language: cfg.LanguageOrDefault(), CompanyName: branding.CompanyName, CompanyLogoURL: branding.CompanyLogoURL, PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), OIDCEnabled: cfg.OIDC.Enabled, }) } // redirectPortalSSORejected sends a portal-initiated enterprise SSO attempt back // to the portal login with a reason, instead of starting a flow that would hand // a visitor a staff session. func redirectPortalSSORejected(ctx *gin.Context, errorParam string) { ctx.Redirect( http.StatusFound, "/support/login?"+errorParam+"="+url.QueryEscape(i18nx.T(ctx, "error.auth.staffSsoOnPortal")), ) } func rejectPortalSSO(ctx *gin.Context, errorParam string) bool { if !loginaudience.Resolve(ctx.Query("next")).IsPortal() { return false } slog.Warn("rejected enterprise SSO started from the visitor portal", "provider", errorParam, "next", ctx.Query("next"), "clientIp", ctx.ClientIP(), ) redirectPortalSSORejected(ctx, errorParam) return true } func OIDCLogin(ctx *gin.Context) { if rejectPortalSSO(ctx, "oidcError") { return } loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next")) if err != nil { ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error()))) return } ctx.Redirect(http.StatusFound, loginURL) } func OIDCCallback(ctx *gin.Context) { if oauthErr := ctx.Query("error"); oauthErr != "" { desc := ctx.Query("error_description") slog.Warn("oidc callback returned oauth error", "error", oauthErr, "description", desc) errMsg := oauthErr if desc != "" { errMsg += ": " + desc } ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg)) return } code := ctx.Query("code") state := ctx.Query("state") if strings.TrimSpace(code) == "" { slog.Warn("oidc callback missing code", "rawQuery", ctx.Request.URL.RawQuery) } cfg := config.Current() ticket, next, err := services.OIDCLoginService.LoginByOIDC( ctx.Request.Context(), code, state, cfg.Auth, ctx.ClientIP(), ctx.GetHeader("User-Agent"), ) if err != nil { slog.Error("oidc callback login failed", "error", err) ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error()))) return } ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next)) } func OIDCExchange(ctx *gin.Context) { req := request.OIDCExchangeRequest{} if err := params.ReadJSON(ctx, &req); err != nil { httpx.WriteJSON(ctx, err) return } ret, err := services.OIDCLoginService.ExchangeOIDCLoginTicket(req.Ticket) if err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, ret) } func Logout(ctx *gin.Context) { if err := services.AuthService.Logout(ctx.GetHeader("Authorization")); err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, nil) } func Profile(ctx *gin.Context) { ret, err := services.AuthService.CurrentProfile(ctx) if err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, ret) } func UpdateProfile(ctx *gin.Context) { req := request.UpdateProfileRequest{} if err := params.ReadJSON(ctx, &req); err != nil { httpx.WriteJSON(ctx, err) return } ret, err := services.AuthService.UpdateProfile(ctx, req) if err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, ret) } func UploadProfileAvatar(ctx *gin.Context) { principal, err := services.AuthService.Authenticate(ctx) if err != nil { httpx.WriteJSON(ctx, err) return } header, err := ctx.FormFile("file") if err != nil { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0323")) return } if !strings.HasPrefix(strings.ToLower(header.Header.Get("Content-Type")), "image/") { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0090")) return } item, err := services.AssetService.UploadFile(header, "avatars", principal) if err != nil { httpx.WriteJSON(ctx, err) return } httpx.WriteJSON(ctx, builders.BuildAsset(item)) } func loginErrorMessage(message string) string { if idx := strings.Index(message, ": "); idx >= 0 { message = message[idx+2:] } return message }