adding back oidc

This commit is contained in:
d34dscene
2025-06-25 00:44:18 +02:00
parent 82d98d8158
commit 882583b4a6
27 changed files with 1017 additions and 834 deletions
+2 -2
View File
@@ -71,7 +71,7 @@ require (
github.com/google/go-github/v28 v28.1.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/http-wasm/http-wasm-host-go v0.7.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -115,7 +115,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.66.0 // indirect
modernc.org/libc v1.66.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+4 -4
View File
@@ -130,8 +130,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 h1:+epNPbD5EqgpEMm5wrl4Hqts3jZt8+kYaqUisuuIGTk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/http-wasm/http-wasm-host-go v0.7.0 h1:+1KrRyOO6tWiDB24QrtSYyDmzFLBBs3jioKaUT0mq1c=
@@ -323,8 +323,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.0.3 h1:y81b9r3asCh6Xtse6Nz85aYGB0cG3M3U6222yap1KWI=
modernc.org/goabi0 v0.0.3/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.0 h1:eoFuDb1ozurUY5WSWlgvxHp0FuL+AncMwNjFqGYMJPQ=
modernc.org/libc v1.66.0/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo=
modernc.org/libc v1.66.1 h1:4uQsntXbVyAgrV+j6NhKvDiUypoJL48BWQx6sy9y8ok=
modernc.org/libc v1.66.1/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+62 -101
View File
@@ -4,11 +4,9 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -16,7 +14,7 @@ import (
"github.com/mizuchilabs/mantrae/internal/config"
"github.com/mizuchilabs/mantrae/internal/settings"
"github.com/mizuchilabs/mantrae/internal/store/db"
"github.com/mizuchilabs/mantrae/internal/util"
"github.com/mizuchilabs/mantrae/pkg/meta"
"golang.org/x/oauth2"
)
@@ -41,17 +39,12 @@ type OIDCUserInfo struct {
func OIDCLogin(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, oauth2Config, _, err := setupOIDCConfig(r.Context(), a)
oauth2Config, _, err := getOIDCConfig(r.Context(), r, a)
if err != nil {
http.Error(w, "OIDC not configured: "+err.Error(), http.StatusServiceUnavailable)
return
}
if !oidcConfig.Enabled {
http.Error(w, "OIDC disabled", http.StatusServiceUnavailable)
return
}
// Generate state for CSRF protection
state, err := generateRandomState()
if err != nil {
@@ -73,7 +66,7 @@ func OIDCLogin(a *config.App) http.HandlerFunc {
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
// Add PKCE if enabled
if oidcConfig.UsePKCE {
if oauth2Config.ClientSecret == "" {
verifier := oauth2.GenerateVerifier()
http.SetCookie(w, &http.Cookie{
Name: "pkce_verifier",
@@ -94,7 +87,7 @@ func OIDCLogin(a *config.App) http.HandlerFunc {
func OIDCCallback(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, oauth2Config, verifier, err := setupOIDCConfig(r.Context(), a)
oauth2Config, verifier, err := getOIDCConfig(r.Context(), r, a)
if err != nil {
http.Error(w, "OIDC not configured: "+err.Error(), http.StatusServiceUnavailable)
return
@@ -125,7 +118,7 @@ func OIDCCallback(a *config.App) http.HandlerFunc {
opts := []oauth2.AuthCodeOption{}
// Handle PKCE
if oidcConfig.UsePKCE {
if oauth2Config.ClientSecret == "" {
verifierCookie, err := r.Cookie("pkce_verifier")
if err != nil {
http.Error(w, "PKCE verifier not found", http.StatusBadRequest)
@@ -194,9 +187,9 @@ func OIDCCallback(a *config.App) http.HandlerFunc {
return
}
// Generate JWT and set cookie
// Generate JWT
expirationTime := time.Now().Add(24 * time.Hour)
jwtToken, err := util.EncodeUserJWT(user.Username, a.Secret, expirationTime)
jwtToken, err := meta.EncodeUserToken(user.ID, a.Secret, expirationTime)
if err != nil {
http.Error(w, "Failed to generate JWT", http.StatusInternalServerError)
return
@@ -207,7 +200,7 @@ func OIDCCallback(a *config.App) http.HandlerFunc {
}
http.SetCookie(w, &http.Cookie{
Name: util.CookieName,
Name: meta.CookieName,
Value: jwtToken,
Path: "/",
MaxAge: int(expirationTime.Unix() - time.Now().Unix()),
@@ -215,106 +208,49 @@ func OIDCCallback(a *config.App) http.HandlerFunc {
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
}
func OIDCStatus(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
oidcConfig, _, _, err := setupOIDCConfig(r.Context(), a)
if err != nil {
slog.Error("Failed to get OIDC config", "error", err)
}
response := map[string]interface{}{
"enabled": false,
"provider": "",
"loginDisabled": false,
}
if err == nil && oidcConfig != nil {
providerName, _ := a.SM.Get(settings.KeyOIDCProviderName)
pwLogin, _ := a.SM.Get(settings.KeyPasswordLoginEnabled)
response["enabled"] = oidcConfig.Enabled
response["provider"] = providerName
response["loginDisabled"] = settings.AsBool(pwLogin)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// Helper function that handles both config and OIDC setup
func setupOIDCConfig(
func getOIDCConfig(
ctx context.Context,
r *http.Request,
a *config.App,
) (*OIDCConfig, *oauth2.Config, *oidc.IDTokenVerifier, error) {
config, err := getOIDCConfig(ctx, a)
if err != nil {
return nil, nil, nil, err
}
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(config.IssuerURL, "/"))
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}
// Create OAuth2 config
oauth2Config := &oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: config.Scopes,
}
// For PKCE, don't include client secret
if config.UsePKCE {
oauth2Config.ClientSecret = ""
}
// Create ID token verifier
verifier := provider.Verifier(&oidc.Config{
ClientID: config.ClientID,
})
return config, oauth2Config, verifier, nil
}
func getOIDCConfig(ctx context.Context, a *config.App) (*OIDCConfig, error) {
config := &OIDCConfig{Scopes: []string{"openid", "email", "profile"}}
) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
sets := a.SM.GetAll()
// Parse settings (same as before but simplified validation)
if enabled, exists := sets[settings.KeyOIDCEnabled]; exists {
config.Enabled = settings.AsBool(enabled)
if enabled, ok := sets[settings.KeyOIDCEnabled]; ok {
if !settings.AsBool(enabled) {
return nil, nil, errors.New("oidc disabled")
}
}
// Return early if disabled
if !config.Enabled {
return config, nil
}
if pkce, exists := sets[settings.KeyOIDCPKCE]; exists {
config.UsePKCE = settings.AsBool(pkce)
}
if clientID, exists := sets[settings.KeyOIDCClientID]; exists {
config := &oauth2.Config{}
if clientID, ok := sets[settings.KeyOIDCClientID]; ok {
config.ClientID = clientID
}
if !config.UsePKCE {
if clientSecret, exists := sets[settings.KeyOIDCClientSecret]; exists {
config.ClientSecret = clientSecret
if clientSecret, ok := sets[settings.KeyOIDCClientSecret]; ok {
config.ClientSecret = clientSecret
}
if pkce, ok := sets[settings.KeyOIDCPKCE]; ok {
if settings.AsBool(pkce) {
config.ClientSecret = ""
}
}
if serverURL, exists := sets[settings.KeyServerURL]; exists {
if parsed, err := url.Parse(serverURL); err == nil {
config.RedirectURL = strings.TrimSuffix(parsed.String(), "/") + "/api/oidc/callback"
}
config.RedirectURL = getRedirectURL(r)
issuerURL, ok := sets[settings.KeyOIDCIssuerURL]
if !ok {
return nil, nil, errors.New("oidc issuer url not set")
}
if issuerURL, exists := sets[settings.KeyOIDCIssuerURL]; exists {
config.IssuerURL = issuerURL
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(issuerURL, "/"))
if err != nil {
return nil, nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}
config.Endpoint = provider.Endpoint()
config.Scopes = []string{"openid", "email", "profile"}
if scopes, exists := sets["oauth_scopes"]; exists && scopes != "" {
config.Scopes = strings.Split(scopes, ",")
for i := range config.Scopes {
@@ -322,7 +258,32 @@ func getOIDCConfig(ctx context.Context, a *config.App) (*OIDCConfig, error) {
}
}
return config, nil
// Create ID token verifier
verifier := provider.Verifier(&oidc.Config{
ClientID: config.ClientID,
})
return config, verifier, nil
}
func getRedirectURL(r *http.Request) string {
proto := "http"
if r.TLS != nil {
proto = "https"
} else if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
proto = forwarded
}
// check url for redirect url
if redirectURL := r.URL.Query().Get("redirect"); redirectURL != "" {
return redirectURL
}
host := r.Host
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
return fmt.Sprintf("%s://%s/oidc/callback", proto, host)
}
func findOrCreateOIDCUser(
+56
View File
@@ -5,11 +5,67 @@ import (
"fmt"
"net/http"
"path/filepath"
"slices"
"time"
"github.com/mizuchilabs/mantrae/internal/config"
"github.com/mizuchilabs/mantrae/internal/storage"
)
func UploadAvatar(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit request size to prevent memory issues
r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB limit
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large or invalid form data", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to get uploaded file", http.StatusBadRequest)
return
}
defer file.Close()
extension := filepath.Ext(header.Filename)
allowedExtensions := []string{".png", ".jpg", ".jpeg"}
if !slices.Contains(allowedExtensions, extension) {
http.Error(w, "Invalid file type", http.StatusBadRequest)
return
}
username := r.URL.Query().Get("username")
if username == "" {
http.Error(w, "Username not provided", http.StatusBadRequest)
return
}
// Generate unique filename
filename := fmt.Sprintf(
"avatar_%s_%s%s",
username,
time.Now().UTC().Format("20060102_150405"),
filepath.Ext(header.Filename),
)
storePath, err := storage.GetBackend(r.Context(), a.SM, "uploads")
if err != nil {
http.Error(w, "Failed to get storage backend", http.StatusInternalServerError)
return
}
storePath.Store(r.Context(), filename, file)
response := map[string]string{"message": "Avatar updated successfully"}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
func UploadBackup(a *config.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit request size to prevent memory issues
+30 -3
View File
@@ -9,7 +9,6 @@ import (
"connectrpc.com/connect"
"github.com/mizuchilabs/mantrae/internal/config"
"github.com/mizuchilabs/mantrae/internal/util"
"github.com/mizuchilabs/mantrae/pkg/meta"
"github.com/mizuchilabs/mantrae/proto/gen/mantrae/v1/mantraev1connect"
"golang.org/x/crypto/bcrypt"
@@ -61,7 +60,7 @@ func (h *MiddlewareHandler) JWTAuth(next http.Handler) http.Handler {
}
token = strings.TrimPrefix(token, "Bearer ")
claims, err := util.DecodeUserJWT(token, h.app.Secret)
claims, err := meta.DecodeUserToken(token, h.app.Secret)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
@@ -95,6 +94,34 @@ func Authentication(app *config.App) connect.UnaryInterceptorFunc {
return next(ctx, req)
}
if cookieHeader := req.Header().Get("Cookie"); cookieHeader != "" {
cookies, err := http.ParseCookie(cookieHeader)
if err != nil {
return nil, connect.NewError(
connect.CodeUnauthenticated,
errors.New("invalid cookie"),
)
}
var sessionID string
for _, cookie := range cookies {
if cookie.Name == meta.CookieName {
sessionID = cookie.Value
}
}
// Validate sessionID as needed and inject into context
if sessionID != "" {
claims, err := meta.DecodeUserToken(sessionID, app.Secret)
if err != nil {
return nil, connect.NewError(
connect.CodeUnauthenticated,
errors.New("invalid token"),
)
}
ctx = context.WithValue(ctx, AuthUserIDKey, claims.UserID)
return next(ctx, req)
}
}
authHeader := req.Header().Get("Authorization")
if authHeader == "" {
return nil, connect.NewError(
@@ -132,7 +159,7 @@ func Authentication(app *config.App) connect.UnaryInterceptorFunc {
}
// Parse and validate the token
claims, err := util.DecodeUserJWT(tokenString, app.Secret)
claims, err := meta.DecodeUserToken(tokenString, app.Secret)
if err != nil {
return nil, connect.NewError(
connect.CodeUnauthenticated,
+9 -3
View File
@@ -28,9 +28,15 @@ func WithCORS(h http.Handler, app *config.App, port string) http.Handler {
return cors.New(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: connectcors.AllowedMethods(),
// AllowedHeaders: connectcors.AllowedHeaders(),
AllowedHeaders: []string{"*"},
ExposedHeaders: connectcors.ExposedHeaders(),
MaxAge: int(2 * time.Hour / time.Second),
AllowedHeaders: append(
connectcors.AllowedHeaders(),
"Authorization",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
),
AllowCredentials: true,
MaxAge: int(2 * time.Hour / time.Second),
}).Handler(h)
}
+6 -2
View File
@@ -238,7 +238,11 @@ func (s *Server) registerServices() {
}
// Upload handler (HTTP) --------------------------------------------------
s.mux.Handle("POST /api/backup", jwtChain(handler.UploadBackup(s.app)))
s.mux.Handle("POST /upload/avatar", jwtChain(handler.UploadAvatar(s.app)))
s.mux.Handle("POST /upload/backup", jwtChain(handler.UploadBackup(s.app)))
// s.mux.Handle("POST /upload/dynamic", jwtChain(handler.UploadBackup(s.app)))
// TODO: OIDC
// OIDC handlers (HTTP) ---------------------------------------------------
s.mux.Handle("GET /oidc/login", logChain(handler.OIDCLogin(s.app)))
s.mux.Handle("GET /oidc/callback", logChain(handler.OIDCCallback(s.app)))
}
+34 -6
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"net/http"
"time"
"connectrpc.com/connect"
@@ -16,6 +17,7 @@ import (
"github.com/mizuchilabs/mantrae/internal/settings"
"github.com/mizuchilabs/mantrae/internal/store/db"
"github.com/mizuchilabs/mantrae/internal/util"
"github.com/mizuchilabs/mantrae/pkg/meta"
mantraev1 "github.com/mizuchilabs/mantrae/proto/gen/mantrae/v1"
)
@@ -50,10 +52,7 @@ func (s *UserService) LoginUser(
}
expirationTime := time.Now().Add(24 * time.Hour)
if req.Msg.Remember {
expirationTime = time.Now().Add(30 * 24 * time.Hour)
}
token, err := util.EncodeUserJWT(user.ID, s.app.Secret, expirationTime)
token, err := meta.EncodeUserToken(user.ID, s.app.Secret, expirationTime)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
@@ -62,7 +61,36 @@ func (s *UserService) LoginUser(
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&mantraev1.LoginUserResponse{Token: token}), nil
cookie := http.Cookie{
Name: meta.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
MaxAge: int(expirationTime.Unix() - time.Now().Unix()),
Secure: req.Header().Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
}
res := connect.NewResponse(&mantraev1.LoginUserResponse{Token: token})
res.Header().Set("Set-Cookie", cookie.String())
return res, nil
}
func (s *UserService) LogoutUser(
ctx context.Context,
req *connect.Request[mantraev1.LogoutUserRequest],
) (*connect.Response[mantraev1.LogoutUserResponse], error) {
cookie := http.Cookie{
Name: meta.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Secure: req.Header().Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
}
res := connect.NewResponse(&mantraev1.LogoutUserResponse{})
res.Header().Set("Set-Cookie", cookie.String())
return res, nil
}
func (s *UserService) VerifyJWT(
@@ -115,7 +143,7 @@ func (s *UserService) VerifyOTP(
}
expirationTime := time.Now().Add(1 * time.Hour)
token, err := util.EncodeUserJWT(user.ID, s.app.Secret, expirationTime)
token, err := meta.EncodeUserToken(user.ID, s.app.Secret, expirationTime)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
+5 -34
View File
@@ -18,6 +18,8 @@ import (
"github.com/mizuchilabs/mantrae/internal/util"
)
const BackupPath = "backups"
type BackupManager struct {
Conn *store.Connection
SM *settings.SettingsManager
@@ -58,42 +60,11 @@ func (m *BackupManager) Stop() {
}
func (m *BackupManager) SetStorage(ctx context.Context) error {
backupStorage, ok := m.SM.Get(settings.KeyBackupStorage)
if !ok {
return fmt.Errorf("failed to get backup storage setting")
}
storageType := storage.BackendType(backupStorage)
if !storageType.Valid() {
return fmt.Errorf("storage backend not configured")
}
var err error
var newStorage storage.Backend
switch storageType {
case storage.BackendTypeLocal:
path, ok := m.SM.Get(settings.KeyBackupPath)
if !ok {
return fmt.Errorf("failed to get backup path")
}
newStorage, err = storage.NewLocalStorage(path)
if err != nil {
return fmt.Errorf("failed to create local storage: %w", err)
}
slog.Debug("backup storage set to local", "path", path)
case storage.BackendTypeS3:
newStorage, err = storage.NewS3Storage(ctx, m.SM)
if err != nil {
return fmt.Errorf("failed to create s3 storage: %w", err)
}
slog.Debug("backup storage set to S3")
default:
return fmt.Errorf("unsupported backend type: %s", storageType)
m.Storage, err = storage.GetBackend(ctx, m.SM, BackupPath)
if err != nil {
return fmt.Errorf("failed to get storage backend: %w", err)
}
m.Storage = newStorage
return nil
}
+1 -2
View File
@@ -4,13 +4,12 @@ package settings
const (
// General settings
KeyServerURL = "server_url"
KeyStorage = "storage_select"
// Backup settings
KeyBackupEnabled = "backup_enabled"
KeyBackupInterval = "backup_interval"
KeyBackupKeep = "backup_keep"
KeyBackupStorage = "backup_storage_select"
KeyBackupPath = "backup_path"
// S3 settings
KeyS3Endpoint = "s3_endpoint"
+1 -2
View File
@@ -19,11 +19,10 @@ import (
// Settings defines all application settings
type Settings struct {
ServerURL string `setting:"server_url" default:""`
Storage string `setting:"storage_select" default:"local"`
BackupEnabled bool `setting:"backup_enabled" default:"true"`
BackupInterval time.Duration `setting:"backup_interval" default:"24h"`
BackupKeep int `setting:"backup_keep" default:"3"`
BackupStorage string `setting:"backup_storage_select" default:"local"`
BackupPath string `setting:"backup_path" default:"backups"`
S3Endpoint string `setting:"s3_endpoint" default:""`
S3Bucket string `setting:"s3_bucket" default:"mantrae"`
S3Region string `setting:"s3_region" default:"us-east-1"`
+19 -6
View File
@@ -2,8 +2,11 @@ package storage
import (
"context"
"errors"
"io"
"time"
"github.com/mizuchilabs/mantrae/internal/settings"
)
type BackendType string
@@ -11,7 +14,6 @@ type BackendType string
const (
BackendTypeLocal BackendType = "local"
BackendTypeS3 BackendType = "s3"
// BackendTypeGit BackendType = "git" //TODO: For future implementation
)
// Backend defines interface for different storage solutions
@@ -28,11 +30,22 @@ type StoredFile struct {
Timestamp time.Time `json:"timestamp,omitempty"`
}
func (t BackendType) Valid() bool {
switch t {
case BackendTypeLocal, BackendTypeS3:
return true
func GetBackend(
ctx context.Context,
sm *settings.SettingsManager,
path string,
) (Backend, error) {
backendSetting, ok := sm.Get(settings.KeyStorage)
if !ok {
return nil, errors.New("failed to get storage backend")
}
switch BackendType(backendSetting) {
case BackendTypeLocal:
return NewLocalStorage(path)
case BackendTypeS3:
return NewS3Storage(ctx, sm)
default:
return false
return nil, errors.New("invalid storage backend")
}
}
-52
View File
@@ -1,52 +0,0 @@
package util
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
const CookieName = "auth_token"
type UserClaims struct {
UserID string `json:"user_id,omitempty"`
jwt.RegisteredClaims
}
// EncodeUserJWT generates a JWT for user login
func EncodeUserJWT(userID, secret string, expirationTime time.Time) (string, error) {
if userID == "" {
return "", errors.New("username cannot be empty")
}
if expirationTime.IsZero() {
expirationTime = time.Now().Add(24 * time.Hour)
}
claims := &UserClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// DecodeUserJWT decodes the user token and returns claims if valid
func DecodeUserJWT(tokenString, secret string) (*UserClaims, error) {
claims := &UserClaims{}
token, err := jwt.ParseWithClaims(
tokenString,
claims,
func(token *jwt.Token) (any, error) {
return []byte(secret), nil
},
)
if err != nil || !token.Valid {
return nil, err
}
return claims, nil
}
+54 -54
View File
@@ -20,6 +20,60 @@ type AgentClaims struct {
jwt.RegisteredClaims
}
func (u *UserClaims) Valid() error {
if u.UserID == "" {
return errors.New("user id is required")
}
return nil
}
func (u *UserClaims) IsExpired() bool {
return u.ExpiresAt.Before(time.Now())
}
func DecodeUserToken(tokenStr string, secret string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(
tokenStr,
&UserClaims{},
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
},
)
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
claims, ok := token.Claims.(*UserClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func EncodeUserToken(
userID, secret string,
expirationTime time.Time,
) (string, error) {
claims := &UserClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
if err := claims.Valid(); err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func (a *AgentClaims) Valid() error {
if a.AgentID == "" {
return errors.New("agent id is required")
@@ -92,57 +146,3 @@ func EncodeAgentToken(
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func (u *UserClaims) Valid() error {
if u.UserID == "" {
return errors.New("user id is required")
}
return nil
}
func (u *UserClaims) IsExpired() bool {
return u.ExpiresAt.Before(time.Now())
}
func DecodeUserToken(tokenStr string, secret string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(
tokenStr,
&UserClaims{},
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
},
)
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
claims, ok := token.Claims.(*UserClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func EncodeUserToken(
userID, secret string,
expirationTime time.Time,
) (string, error) {
claims := &UserClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
if err := claims.Valid(); err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
+6
View File
@@ -0,0 +1,6 @@
package meta
const (
CookieName = "auth_token"
HeaderAgentID = "Mantrae-Agent-Id"
)
-3
View File
@@ -1,3 +0,0 @@
package meta
const HeaderAgentID = "Mantrae-Agent-Id"
@@ -35,6 +35,8 @@ const (
const (
// UserServiceLoginUserProcedure is the fully-qualified name of the UserService's LoginUser RPC.
UserServiceLoginUserProcedure = "/mantrae.v1.UserService/LoginUser"
// UserServiceLogoutUserProcedure is the fully-qualified name of the UserService's LogoutUser RPC.
UserServiceLogoutUserProcedure = "/mantrae.v1.UserService/LogoutUser"
// UserServiceVerifyJWTProcedure is the fully-qualified name of the UserService's VerifyJWT RPC.
UserServiceVerifyJWTProcedure = "/mantrae.v1.UserService/VerifyJWT"
// UserServiceVerifyOTPProcedure is the fully-qualified name of the UserService's VerifyOTP RPC.
@@ -59,6 +61,7 @@ const (
// UserServiceClient is a client for the mantrae.v1.UserService service.
type UserServiceClient interface {
LoginUser(context.Context, *connect.Request[v1.LoginUserRequest]) (*connect.Response[v1.LoginUserResponse], error)
LogoutUser(context.Context, *connect.Request[v1.LogoutUserRequest]) (*connect.Response[v1.LogoutUserResponse], error)
VerifyJWT(context.Context, *connect.Request[v1.VerifyJWTRequest]) (*connect.Response[v1.VerifyJWTResponse], error)
VerifyOTP(context.Context, *connect.Request[v1.VerifyOTPRequest]) (*connect.Response[v1.VerifyOTPResponse], error)
SendOTP(context.Context, *connect.Request[v1.SendOTPRequest]) (*connect.Response[v1.SendOTPResponse], error)
@@ -87,6 +90,12 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts ..
connect.WithSchema(userServiceMethods.ByName("LoginUser")),
connect.WithClientOptions(opts...),
),
logoutUser: connect.NewClient[v1.LogoutUserRequest, v1.LogoutUserResponse](
httpClient,
baseURL+UserServiceLogoutUserProcedure,
connect.WithSchema(userServiceMethods.ByName("LogoutUser")),
connect.WithClientOptions(opts...),
),
verifyJWT: connect.NewClient[v1.VerifyJWTRequest, v1.VerifyJWTResponse](
httpClient,
baseURL+UserServiceVerifyJWTProcedure,
@@ -149,6 +158,7 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts ..
// userServiceClient implements UserServiceClient.
type userServiceClient struct {
loginUser *connect.Client[v1.LoginUserRequest, v1.LoginUserResponse]
logoutUser *connect.Client[v1.LogoutUserRequest, v1.LogoutUserResponse]
verifyJWT *connect.Client[v1.VerifyJWTRequest, v1.VerifyJWTResponse]
verifyOTP *connect.Client[v1.VerifyOTPRequest, v1.VerifyOTPResponse]
sendOTP *connect.Client[v1.SendOTPRequest, v1.SendOTPResponse]
@@ -165,6 +175,11 @@ func (c *userServiceClient) LoginUser(ctx context.Context, req *connect.Request[
return c.loginUser.CallUnary(ctx, req)
}
// LogoutUser calls mantrae.v1.UserService.LogoutUser.
func (c *userServiceClient) LogoutUser(ctx context.Context, req *connect.Request[v1.LogoutUserRequest]) (*connect.Response[v1.LogoutUserResponse], error) {
return c.logoutUser.CallUnary(ctx, req)
}
// VerifyJWT calls mantrae.v1.UserService.VerifyJWT.
func (c *userServiceClient) VerifyJWT(ctx context.Context, req *connect.Request[v1.VerifyJWTRequest]) (*connect.Response[v1.VerifyJWTResponse], error) {
return c.verifyJWT.CallUnary(ctx, req)
@@ -213,6 +228,7 @@ func (c *userServiceClient) GetOIDCStatus(ctx context.Context, req *connect.Requ
// UserServiceHandler is an implementation of the mantrae.v1.UserService service.
type UserServiceHandler interface {
LoginUser(context.Context, *connect.Request[v1.LoginUserRequest]) (*connect.Response[v1.LoginUserResponse], error)
LogoutUser(context.Context, *connect.Request[v1.LogoutUserRequest]) (*connect.Response[v1.LogoutUserResponse], error)
VerifyJWT(context.Context, *connect.Request[v1.VerifyJWTRequest]) (*connect.Response[v1.VerifyJWTResponse], error)
VerifyOTP(context.Context, *connect.Request[v1.VerifyOTPRequest]) (*connect.Response[v1.VerifyOTPResponse], error)
SendOTP(context.Context, *connect.Request[v1.SendOTPRequest]) (*connect.Response[v1.SendOTPResponse], error)
@@ -237,6 +253,12 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption
connect.WithSchema(userServiceMethods.ByName("LoginUser")),
connect.WithHandlerOptions(opts...),
)
userServiceLogoutUserHandler := connect.NewUnaryHandler(
UserServiceLogoutUserProcedure,
svc.LogoutUser,
connect.WithSchema(userServiceMethods.ByName("LogoutUser")),
connect.WithHandlerOptions(opts...),
)
userServiceVerifyJWTHandler := connect.NewUnaryHandler(
UserServiceVerifyJWTProcedure,
svc.VerifyJWT,
@@ -297,6 +319,8 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption
switch r.URL.Path {
case UserServiceLoginUserProcedure:
userServiceLoginUserHandler.ServeHTTP(w, r)
case UserServiceLogoutUserProcedure:
userServiceLogoutUserHandler.ServeHTTP(w, r)
case UserServiceVerifyJWTProcedure:
userServiceVerifyJWTHandler.ServeHTTP(w, r)
case UserServiceVerifyOTPProcedure:
@@ -328,6 +352,10 @@ func (UnimplementedUserServiceHandler) LoginUser(context.Context, *connect.Reque
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("mantrae.v1.UserService.LoginUser is not implemented"))
}
func (UnimplementedUserServiceHandler) LogoutUser(context.Context, *connect.Request[v1.LogoutUserRequest]) (*connect.Response[v1.LogoutUserResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("mantrae.v1.UserService.LogoutUser is not implemented"))
}
func (UnimplementedUserServiceHandler) VerifyJWT(context.Context, *connect.Request[v1.VerifyJWTRequest]) (*connect.Response[v1.VerifyJWTResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("mantrae.v1.UserService.VerifyJWT is not implemented"))
}
File diff suppressed because it is too large Load Diff
+43 -10
View File
@@ -2215,9 +2215,6 @@ components:
type: string
title: password
minLength: 8
remember:
type: boolean
title: remember
title: LoginUserRequest
required:
- password
@@ -2230,6 +2227,14 @@ components:
title: token
title: LoginUserResponse
additionalProperties: false
mantrae.v1.LogoutUserRequest:
type: object
title: LogoutUserRequest
additionalProperties: false
mantrae.v1.LogoutUserResponse:
type: object
title: LogoutUserResponse
additionalProperties: false
mantrae.v1.SendOTPRequest:
type: object
anyOf:
@@ -2331,14 +2336,7 @@ components:
additionalProperties: false
mantrae.v1.VerifyJWTRequest:
type: object
properties:
token:
type: string
title: token
minLength: 1
title: VerifyJWTRequest
required:
- token
additionalProperties: false
mantrae.v1.VerifyJWTResponse:
type: object
@@ -5131,6 +5129,41 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/mantrae.v1.LoginUserResponse'
/mantrae.v1.UserService/LogoutUser:
post:
tags:
- mantrae.v1.UserService
summary: LogoutUser
operationId: mantrae.v1.UserService.LogoutUser
parameters:
- name: Connect-Protocol-Version
in: header
required: true
schema:
$ref: '#/components/schemas/connect-protocol-version'
- name: Connect-Timeout-Ms
in: header
schema:
$ref: '#/components/schemas/connect-timeout-header'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/mantrae.v1.LogoutUserRequest'
required: true
responses:
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/connect.error'
"200":
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/mantrae.v1.LogoutUserResponse'
/mantrae.v1.UserService/VerifyJWT:
post:
tags:
+5 -7
View File
@@ -7,6 +7,7 @@ import "google/protobuf/timestamp.proto";
service UserService {
rpc LoginUser(LoginUserRequest) returns (LoginUserResponse);
rpc LogoutUser(LogoutUserRequest) returns (LogoutUserResponse);
rpc VerifyJWT(VerifyJWTRequest) returns (VerifyJWTResponse);
rpc VerifyOTP(VerifyOTPRequest) returns (VerifyOTPResponse);
rpc SendOTP(SendOTPRequest) returns (SendOTPResponse);
@@ -45,18 +46,15 @@ message LoginUserRequest {
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 8
];
bool remember = 4;
}
message LoginUserResponse {
string token = 1;
}
message VerifyJWTRequest {
string token = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 1
];
}
message LogoutUserRequest {}
message LogoutUserResponse {}
message VerifyJWTRequest {}
message VerifyJWTResponse {
User user = 1;
}
+16 -24
View File
@@ -23,39 +23,31 @@ export const BASE_URL = import.meta.env.PROD
? ""
: `http://127.0.0.1:${BACKEND_PORT}`;
export function useClient<T extends DescService>(
service: T,
fetch?: typeof window.fetch,
): Client<T> {
// Custom fetch function that adds the Authorization header
const customFetch: typeof window.fetch = async (url, options) => {
const headers = new Headers(options?.headers); // Get existing headers
if (token.value) {
headers.set("Authorization", "Bearer " + token.value); // Add the Authorization header
}
const customOptions = {
...options,
headers,
};
return fetch ? fetch(url, customOptions) : window.fetch(url, customOptions); // Use custom fetch or default
};
export function useClient<T extends DescService>(service: T): Client<T> {
const headers = new Headers();
headers.set("Content-Type", "application/json");
// if (token.value) {
// headers.set("Authorization", "Bearer " + token.value);
// }
const transport = createConnectTransport({
baseUrl: BASE_URL,
fetch: customFetch,
fetch: (input, init) =>
fetch(input, {
...init,
headers,
credentials: "include",
}),
});
return createClient(service, transport);
}
export function logout() {
token.value = null;
user.clear();
goto("/login");
export function handleOIDCLogin() {
window.location.href = `${BASE_URL}/oidc/login`;
}
export async function upload(event: Event, endpoint: string) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
export async function upload(input: HTMLInputElement | null, endpoint: string) {
if (!input?.files?.length) return;
const body = new FormData();
body.append("file", input.files[0]);
+9 -2
View File
@@ -27,11 +27,12 @@
} from '@lucide/svelte';
import { profile } from '$lib/stores/profile';
import { user } from '$lib/stores/user';
import { logout, profileClient } from '$lib/api';
import { profileClient, userClient } from '$lib/api';
import type { Profile } from '$lib/gen/mantrae/v1/profile_pb';
import ProfileModal from '$lib/components/modals/profile.svelte';
import UserModal from '$lib/components/modals/user.svelte';
import { toggleMode, mode } from 'mode-watcher';
import { goto } from '$app/navigation';
let { ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
@@ -278,7 +279,13 @@
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => logout()}>
<DropdownMenu.Item
onSelect={() => {
userClient.logoutUser({});
user.clear();
goto('/login');
}}
>
<LogOut />
Log out
</DropdownMenu.Item>
+53 -28
View File
@@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file mantrae/v1/user.proto.
*/
export const file_mantrae_v1_user: GenFile = /*@__PURE__*/
fileDesc("ChVtYW50cmFlL3YxL3VzZXIucHJvdG8SCm1hbnRyYWUudjEipAIKBFVzZXISCgoCaWQYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSEAoIcGFzc3dvcmQYAyABKAkSDQoFZW1haWwYBCABKAkSEAoIaXNfYWRtaW4YBSABKAgSCwoDb3RwGAYgASgJEi4KCm90cF9leHBpcnkYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmxhc3RfbG9naW4YCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIo4BChBMb2dpblVzZXJSZXF1ZXN0EhsKCHVzZXJuYW1lGAEgASgJQge6SARyAhADSAASGAoFZW1haWwYAiABKAlCB7pIBHICYAFIABIcCghwYXNzd29yZBgDIAEoCUIKukgHyAEBcgIQCBIQCghyZW1lbWJlchgEIAEoCEITCgppZGVudGlmaWVyEgW6SAIIASIiChFMb2dpblVzZXJSZXNwb25zZRINCgV0b2tlbhgBIAEoCSItChBWZXJpZnlKV1RSZXF1ZXN0EhkKBXRva2VuGAEgASgJQgq6SAfIAQFyAhABIjMKEVZlcmlmeUpXVFJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIidwoQVmVyaWZ5T1RQUmVxdWVzdBIbCgh1c2VybmFtZRgBIAEoCUIHukgEcgIQA0gAEhgKBWVtYWlsGAIgASgJQge6SARyAmABSAASFwoDb3RwGAMgASgJQgq6SAfIAQFyAhAGQhMKCmlkZW50aWZpZXISBbpIAggBIiIKEVZlcmlmeU9UUFJlc3BvbnNlEg0KBXRva2VuGAEgASgJIlwKDlNlbmRPVFBSZXF1ZXN0EhsKCHVzZXJuYW1lGAEgASgJQge6SARyAhADSAASGAoFZW1haWwYAiABKAlCB7pIBHICYAFIAEITCgppZGVudGlmaWVyEgW6SAIIASIRCg9TZW5kT1RQUmVzcG9uc2UicwoOR2V0VXNlclJlcXVlc3QSFQoCaWQYASABKAlCB7pIBHICEAFIABIbCgh1c2VybmFtZRgCIAEoCUIHukgEcgIQA0gAEhgKBWVtYWlsGAMgASgJQge6SARyAmABSABCEwoKaWRlbnRpZmllchIFukgCCAEiMQoPR2V0VXNlclJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIieQoRQ3JlYXRlVXNlclJlcXVlc3QSHAoIdXNlcm5hbWUYASABKAlCCrpIB8gBAXICEAMSHAoIcGFzc3dvcmQYAiABKAlCCrpIB8gBAXICEAgSFgoFZW1haWwYAyABKAlCB7pIBHICYAESEAoIaXNfYWRtaW4YBCABKAgiNAoSQ3JlYXRlVXNlclJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIioAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EhYKAmlkGAEgASgJQgq6SAfIAQFyAhABEhwKCHVzZXJuYW1lGAIgASgJQgq6SAfIAQFyAhADEhYKBWVtYWlsGAMgASgJQge6SARyAmABEhAKCGlzX2FkbWluGAQgASgIEh4KCHBhc3N3b3JkGAUgASgJQge6SARyAhAISACIAQFCCwoJX3Bhc3N3b3JkIjQKElVwZGF0ZVVzZXJSZXNwb25zZRIeCgR1c2VyGAEgASgLMhAubWFudHJhZS52MS5Vc2VyIisKEURlbGV0ZVVzZXJSZXF1ZXN0EhYKAmlkGAEgASgJQgq6SAfIAQFyAhABIhQKEkRlbGV0ZVVzZXJSZXNwb25zZSKxAQoQTGlzdFVzZXJzUmVxdWVzdBJqCgVsaW1pdBgBIAEoA0JWukhTugFQCgtsaW1pdC52YWxpZBIpbGltaXQgbXVzdCBiZSBlaXRoZXIgLTEgb3IgZ3JlYXRlciB0aGFuIDAaFnRoaXMgPT0gLTEgfHwgdGhpcyA+IDBIAIgBARIcCgZvZmZzZXQYAiABKANCB7pIBCICKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCJJChFMaXN0VXNlcnNSZXNwb25zZRIfCgV1c2VycxgBIAMoCzIQLm1hbnRyYWUudjEuVXNlchITCgt0b3RhbF9jb3VudBgCIAEoAyIWChRHZXRPSURDU3RhdHVzUmVxdWVzdCJWChVHZXRPSURDU3RhdHVzUmVzcG9uc2USFAoMb2lkY19lbmFibGVkGAEgASgIEhUKDWxvZ2luX2VuYWJsZWQYAiABKAgSEAoIcHJvdmlkZXIYAyABKAkyhAYKC1VzZXJTZXJ2aWNlEkgKCUxvZ2luVXNlchIcLm1hbnRyYWUudjEuTG9naW5Vc2VyUmVxdWVzdBodLm1hbnRyYWUudjEuTG9naW5Vc2VyUmVzcG9uc2USSAoJVmVyaWZ5SldUEhwubWFudHJhZS52MS5WZXJpZnlKV1RSZXF1ZXN0Gh0ubWFudHJhZS52MS5WZXJpZnlKV1RSZXNwb25zZRJICglWZXJpZnlPVFASHC5tYW50cmFlLnYxLlZlcmlmeU9UUFJlcXVlc3QaHS5tYW50cmFlLnYxLlZlcmlmeU9UUFJlc3BvbnNlEkIKB1NlbmRPVFASGi5tYW50cmFlLnYxLlNlbmRPVFBSZXF1ZXN0GhsubWFudHJhZS52MS5TZW5kT1RQUmVzcG9uc2USRwoHR2V0VXNlchIaLm1hbnRyYWUudjEuR2V0VXNlclJlcXVlc3QaGy5tYW50cmFlLnYxLkdldFVzZXJSZXNwb25zZSIDkAIBEksKCkNyZWF0ZVVzZXISHS5tYW50cmFlLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0Gh4ubWFudHJhZS52MS5DcmVhdGVVc2VyUmVzcG9uc2USSwoKVXBkYXRlVXNlchIdLm1hbnRyYWUudjEuVXBkYXRlVXNlclJlcXVlc3QaHi5tYW50cmFlLnYxLlVwZGF0ZVVzZXJSZXNwb25zZRJLCgpEZWxldGVVc2VyEh0ubWFudHJhZS52MS5EZWxldGVVc2VyUmVxdWVzdBoeLm1hbnRyYWUudjEuRGVsZXRlVXNlclJlc3BvbnNlEk0KCUxpc3RVc2VycxIcLm1hbnRyYWUudjEuTGlzdFVzZXJzUmVxdWVzdBodLm1hbnRyYWUudjEuTGlzdFVzZXJzUmVzcG9uc2UiA5ACARJUCg1HZXRPSURDU3RhdHVzEiAubWFudHJhZS52MS5HZXRPSURDU3RhdHVzUmVxdWVzdBohLm1hbnRyYWUudjEuR2V0T0lEQ1N0YXR1c1Jlc3BvbnNlQqMBCg5jb20ubWFudHJhZS52MUIJVXNlclByb3RvUAFaPWdpdGh1Yi5jb20vbWl6dWNoaWxhYnMvbWFudHJhZS9wcm90by9nZW4vbWFudHJhZS92MTttYW50cmFldjGiAgNNWFiqAgpNYW50cmFlLlYxygIKTWFudHJhZVxWMeICFk1hbnRyYWVcVjFcR1BCTWV0YWRhdGHqAgtNYW50cmFlOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_timestamp]);
fileDesc("ChVtYW50cmFlL3YxL3VzZXIucHJvdG8SCm1hbnRyYWUudjEipAIKBFVzZXISCgoCaWQYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSEAoIcGFzc3dvcmQYAyABKAkSDQoFZW1haWwYBCABKAkSEAoIaXNfYWRtaW4YBSABKAgSCwoDb3RwGAYgASgJEi4KCm90cF9leHBpcnkYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmxhc3RfbG9naW4YCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wInwKEExvZ2luVXNlclJlcXVlc3QSGwoIdXNlcm5hbWUYASABKAlCB7pIBHICEANIABIYCgVlbWFpbBgCIAEoCUIHukgEcgJgAUgAEhwKCHBhc3N3b3JkGAMgASgJQgq6SAfIAQFyAhAIQhMKCmlkZW50aWZpZXISBbpIAggBIiIKEUxvZ2luVXNlclJlc3BvbnNlEg0KBXRva2VuGAEgASgJIhMKEUxvZ291dFVzZXJSZXF1ZXN0IhQKEkxvZ291dFVzZXJSZXNwb25zZSISChBWZXJpZnlKV1RSZXF1ZXN0IjMKEVZlcmlmeUpXVFJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIidwoQVmVyaWZ5T1RQUmVxdWVzdBIbCgh1c2VybmFtZRgBIAEoCUIHukgEcgIQA0gAEhgKBWVtYWlsGAIgASgJQge6SARyAmABSAASFwoDb3RwGAMgASgJQgq6SAfIAQFyAhAGQhMKCmlkZW50aWZpZXISBbpIAggBIiIKEVZlcmlmeU9UUFJlc3BvbnNlEg0KBXRva2VuGAEgASgJIlwKDlNlbmRPVFBSZXF1ZXN0EhsKCHVzZXJuYW1lGAEgASgJQge6SARyAhADSAASGAoFZW1haWwYAiABKAlCB7pIBHICYAFIAEITCgppZGVudGlmaWVyEgW6SAIIASIRCg9TZW5kT1RQUmVzcG9uc2UicwoOR2V0VXNlclJlcXVlc3QSFQoCaWQYASABKAlCB7pIBHICEAFIABIbCgh1c2VybmFtZRgCIAEoCUIHukgEcgIQA0gAEhgKBWVtYWlsGAMgASgJQge6SARyAmABSABCEwoKaWRlbnRpZmllchIFukgCCAEiMQoPR2V0VXNlclJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIieQoRQ3JlYXRlVXNlclJlcXVlc3QSHAoIdXNlcm5hbWUYASABKAlCCrpIB8gBAXICEAMSHAoIcGFzc3dvcmQYAiABKAlCCrpIB8gBAXICEAgSFgoFZW1haWwYAyABKAlCB7pIBHICYAESEAoIaXNfYWRtaW4YBCABKAgiNAoSQ3JlYXRlVXNlclJlc3BvbnNlEh4KBHVzZXIYASABKAsyEC5tYW50cmFlLnYxLlVzZXIioAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EhYKAmlkGAEgASgJQgq6SAfIAQFyAhABEhwKCHVzZXJuYW1lGAIgASgJQgq6SAfIAQFyAhADEhYKBWVtYWlsGAMgASgJQge6SARyAmABEhAKCGlzX2FkbWluGAQgASgIEh4KCHBhc3N3b3JkGAUgASgJQge6SARyAhAISACIAQFCCwoJX3Bhc3N3b3JkIjQKElVwZGF0ZVVzZXJSZXNwb25zZRIeCgR1c2VyGAEgASgLMhAubWFudHJhZS52MS5Vc2VyIisKEURlbGV0ZVVzZXJSZXF1ZXN0EhYKAmlkGAEgASgJQgq6SAfIAQFyAhABIhQKEkRlbGV0ZVVzZXJSZXNwb25zZSKxAQoQTGlzdFVzZXJzUmVxdWVzdBJqCgVsaW1pdBgBIAEoA0JWukhTugFQCgtsaW1pdC52YWxpZBIpbGltaXQgbXVzdCBiZSBlaXRoZXIgLTEgb3IgZ3JlYXRlciB0aGFuIDAaFnRoaXMgPT0gLTEgfHwgdGhpcyA+IDBIAIgBARIcCgZvZmZzZXQYAiABKANCB7pIBCICKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCJJChFMaXN0VXNlcnNSZXNwb25zZRIfCgV1c2VycxgBIAMoCzIQLm1hbnRyYWUudjEuVXNlchITCgt0b3RhbF9jb3VudBgCIAEoAyIWChRHZXRPSURDU3RhdHVzUmVxdWVzdCJWChVHZXRPSURDU3RhdHVzUmVzcG9uc2USFAoMb2lkY19lbmFibGVkGAEgASgIEhUKDWxvZ2luX2VuYWJsZWQYAiABKAgSEAoIcHJvdmlkZXIYAyABKAky0QYKC1VzZXJTZXJ2aWNlEkgKCUxvZ2luVXNlchIcLm1hbnRyYWUudjEuTG9naW5Vc2VyUmVxdWVzdBodLm1hbnRyYWUudjEuTG9naW5Vc2VyUmVzcG9uc2USSwoKTG9nb3V0VXNlchIdLm1hbnRyYWUudjEuTG9nb3V0VXNlclJlcXVlc3QaHi5tYW50cmFlLnYxLkxvZ291dFVzZXJSZXNwb25zZRJICglWZXJpZnlKV1QSHC5tYW50cmFlLnYxLlZlcmlmeUpXVFJlcXVlc3QaHS5tYW50cmFlLnYxLlZlcmlmeUpXVFJlc3BvbnNlEkgKCVZlcmlmeU9UUBIcLm1hbnRyYWUudjEuVmVyaWZ5T1RQUmVxdWVzdBodLm1hbnRyYWUudjEuVmVyaWZ5T1RQUmVzcG9uc2USQgoHU2VuZE9UUBIaLm1hbnRyYWUudjEuU2VuZE9UUFJlcXVlc3QaGy5tYW50cmFlLnYxLlNlbmRPVFBSZXNwb25zZRJHCgdHZXRVc2VyEhoubWFudHJhZS52MS5HZXRVc2VyUmVxdWVzdBobLm1hbnRyYWUudjEuR2V0VXNlclJlc3BvbnNlIgOQAgESSwoKQ3JlYXRlVXNlchIdLm1hbnRyYWUudjEuQ3JlYXRlVXNlclJlcXVlc3QaHi5tYW50cmFlLnYxLkNyZWF0ZVVzZXJSZXNwb25zZRJLCgpVcGRhdGVVc2VyEh0ubWFudHJhZS52MS5VcGRhdGVVc2VyUmVxdWVzdBoeLm1hbnRyYWUudjEuVXBkYXRlVXNlclJlc3BvbnNlEksKCkRlbGV0ZVVzZXISHS5tYW50cmFlLnYxLkRlbGV0ZVVzZXJSZXF1ZXN0Gh4ubWFudHJhZS52MS5EZWxldGVVc2VyUmVzcG9uc2USTQoJTGlzdFVzZXJzEhwubWFudHJhZS52MS5MaXN0VXNlcnNSZXF1ZXN0Gh0ubWFudHJhZS52MS5MaXN0VXNlcnNSZXNwb25zZSIDkAIBElQKDUdldE9JRENTdGF0dXMSIC5tYW50cmFlLnYxLkdldE9JRENTdGF0dXNSZXF1ZXN0GiEubWFudHJhZS52MS5HZXRPSURDU3RhdHVzUmVzcG9uc2VCowEKDmNvbS5tYW50cmFlLnYxQglVc2VyUHJvdG9QAVo9Z2l0aHViLmNvbS9taXp1Y2hpbGFicy9tYW50cmFlL3Byb3RvL2dlbi9tYW50cmFlL3YxO21hbnRyYWV2MaICA01YWKoCCk1hbnRyYWUuVjHKAgpNYW50cmFlXFYx4gIWTWFudHJhZVxWMVxHUEJNZXRhZGF0YeoCC01hbnRyYWU6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_timestamp]);
/**
* @generated from message mantrae.v1.User
@@ -102,11 +102,6 @@ export type LoginUserRequest = Message<"mantrae.v1.LoginUserRequest"> & {
* @generated from field: string password = 3;
*/
password: string;
/**
* @generated from field: bool remember = 4;
*/
remember: boolean;
};
/**
@@ -133,14 +128,36 @@ export type LoginUserResponse = Message<"mantrae.v1.LoginUserResponse"> & {
export const LoginUserResponseSchema: GenMessage<LoginUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 2);
/**
* @generated from message mantrae.v1.LogoutUserRequest
*/
export type LogoutUserRequest = Message<"mantrae.v1.LogoutUserRequest"> & {
};
/**
* Describes the message mantrae.v1.LogoutUserRequest.
* Use `create(LogoutUserRequestSchema)` to create a new message.
*/
export const LogoutUserRequestSchema: GenMessage<LogoutUserRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 3);
/**
* @generated from message mantrae.v1.LogoutUserResponse
*/
export type LogoutUserResponse = Message<"mantrae.v1.LogoutUserResponse"> & {
};
/**
* Describes the message mantrae.v1.LogoutUserResponse.
* Use `create(LogoutUserResponseSchema)` to create a new message.
*/
export const LogoutUserResponseSchema: GenMessage<LogoutUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 4);
/**
* @generated from message mantrae.v1.VerifyJWTRequest
*/
export type VerifyJWTRequest = Message<"mantrae.v1.VerifyJWTRequest"> & {
/**
* @generated from field: string token = 1;
*/
token: string;
};
/**
@@ -148,7 +165,7 @@ export type VerifyJWTRequest = Message<"mantrae.v1.VerifyJWTRequest"> & {
* Use `create(VerifyJWTRequestSchema)` to create a new message.
*/
export const VerifyJWTRequestSchema: GenMessage<VerifyJWTRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 3);
messageDesc(file_mantrae_v1_user, 5);
/**
* @generated from message mantrae.v1.VerifyJWTResponse
@@ -165,7 +182,7 @@ export type VerifyJWTResponse = Message<"mantrae.v1.VerifyJWTResponse"> & {
* Use `create(VerifyJWTResponseSchema)` to create a new message.
*/
export const VerifyJWTResponseSchema: GenMessage<VerifyJWTResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 4);
messageDesc(file_mantrae_v1_user, 6);
/**
* @generated from message mantrae.v1.VerifyOTPRequest
@@ -199,7 +216,7 @@ export type VerifyOTPRequest = Message<"mantrae.v1.VerifyOTPRequest"> & {
* Use `create(VerifyOTPRequestSchema)` to create a new message.
*/
export const VerifyOTPRequestSchema: GenMessage<VerifyOTPRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 5);
messageDesc(file_mantrae_v1_user, 7);
/**
* @generated from message mantrae.v1.VerifyOTPResponse
@@ -216,7 +233,7 @@ export type VerifyOTPResponse = Message<"mantrae.v1.VerifyOTPResponse"> & {
* Use `create(VerifyOTPResponseSchema)` to create a new message.
*/
export const VerifyOTPResponseSchema: GenMessage<VerifyOTPResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 6);
messageDesc(file_mantrae_v1_user, 8);
/**
* @generated from message mantrae.v1.SendOTPRequest
@@ -245,7 +262,7 @@ export type SendOTPRequest = Message<"mantrae.v1.SendOTPRequest"> & {
* Use `create(SendOTPRequestSchema)` to create a new message.
*/
export const SendOTPRequestSchema: GenMessage<SendOTPRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 7);
messageDesc(file_mantrae_v1_user, 9);
/**
* @generated from message mantrae.v1.SendOTPResponse
@@ -258,7 +275,7 @@ export type SendOTPResponse = Message<"mantrae.v1.SendOTPResponse"> & {
* Use `create(SendOTPResponseSchema)` to create a new message.
*/
export const SendOTPResponseSchema: GenMessage<SendOTPResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 8);
messageDesc(file_mantrae_v1_user, 10);
/**
* @generated from message mantrae.v1.GetUserRequest
@@ -293,7 +310,7 @@ export type GetUserRequest = Message<"mantrae.v1.GetUserRequest"> & {
* Use `create(GetUserRequestSchema)` to create a new message.
*/
export const GetUserRequestSchema: GenMessage<GetUserRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 9);
messageDesc(file_mantrae_v1_user, 11);
/**
* @generated from message mantrae.v1.GetUserResponse
@@ -310,7 +327,7 @@ export type GetUserResponse = Message<"mantrae.v1.GetUserResponse"> & {
* Use `create(GetUserResponseSchema)` to create a new message.
*/
export const GetUserResponseSchema: GenMessage<GetUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 10);
messageDesc(file_mantrae_v1_user, 12);
/**
* @generated from message mantrae.v1.CreateUserRequest
@@ -342,7 +359,7 @@ export type CreateUserRequest = Message<"mantrae.v1.CreateUserRequest"> & {
* Use `create(CreateUserRequestSchema)` to create a new message.
*/
export const CreateUserRequestSchema: GenMessage<CreateUserRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 11);
messageDesc(file_mantrae_v1_user, 13);
/**
* @generated from message mantrae.v1.CreateUserResponse
@@ -359,7 +376,7 @@ export type CreateUserResponse = Message<"mantrae.v1.CreateUserResponse"> & {
* Use `create(CreateUserResponseSchema)` to create a new message.
*/
export const CreateUserResponseSchema: GenMessage<CreateUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 12);
messageDesc(file_mantrae_v1_user, 14);
/**
* @generated from message mantrae.v1.UpdateUserRequest
@@ -396,7 +413,7 @@ export type UpdateUserRequest = Message<"mantrae.v1.UpdateUserRequest"> & {
* Use `create(UpdateUserRequestSchema)` to create a new message.
*/
export const UpdateUserRequestSchema: GenMessage<UpdateUserRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 13);
messageDesc(file_mantrae_v1_user, 15);
/**
* @generated from message mantrae.v1.UpdateUserResponse
@@ -413,7 +430,7 @@ export type UpdateUserResponse = Message<"mantrae.v1.UpdateUserResponse"> & {
* Use `create(UpdateUserResponseSchema)` to create a new message.
*/
export const UpdateUserResponseSchema: GenMessage<UpdateUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 14);
messageDesc(file_mantrae_v1_user, 16);
/**
* @generated from message mantrae.v1.DeleteUserRequest
@@ -430,7 +447,7 @@ export type DeleteUserRequest = Message<"mantrae.v1.DeleteUserRequest"> & {
* Use `create(DeleteUserRequestSchema)` to create a new message.
*/
export const DeleteUserRequestSchema: GenMessage<DeleteUserRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 15);
messageDesc(file_mantrae_v1_user, 17);
/**
* @generated from message mantrae.v1.DeleteUserResponse
@@ -443,7 +460,7 @@ export type DeleteUserResponse = Message<"mantrae.v1.DeleteUserResponse"> & {
* Use `create(DeleteUserResponseSchema)` to create a new message.
*/
export const DeleteUserResponseSchema: GenMessage<DeleteUserResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 16);
messageDesc(file_mantrae_v1_user, 18);
/**
* @generated from message mantrae.v1.ListUsersRequest
@@ -465,7 +482,7 @@ export type ListUsersRequest = Message<"mantrae.v1.ListUsersRequest"> & {
* Use `create(ListUsersRequestSchema)` to create a new message.
*/
export const ListUsersRequestSchema: GenMessage<ListUsersRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 17);
messageDesc(file_mantrae_v1_user, 19);
/**
* @generated from message mantrae.v1.ListUsersResponse
@@ -487,7 +504,7 @@ export type ListUsersResponse = Message<"mantrae.v1.ListUsersResponse"> & {
* Use `create(ListUsersResponseSchema)` to create a new message.
*/
export const ListUsersResponseSchema: GenMessage<ListUsersResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 18);
messageDesc(file_mantrae_v1_user, 20);
/**
* @generated from message mantrae.v1.GetOIDCStatusRequest
@@ -500,7 +517,7 @@ export type GetOIDCStatusRequest = Message<"mantrae.v1.GetOIDCStatusRequest"> &
* Use `create(GetOIDCStatusRequestSchema)` to create a new message.
*/
export const GetOIDCStatusRequestSchema: GenMessage<GetOIDCStatusRequest> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 19);
messageDesc(file_mantrae_v1_user, 21);
/**
* @generated from message mantrae.v1.GetOIDCStatusResponse
@@ -527,7 +544,7 @@ export type GetOIDCStatusResponse = Message<"mantrae.v1.GetOIDCStatusResponse">
* Use `create(GetOIDCStatusResponseSchema)` to create a new message.
*/
export const GetOIDCStatusResponseSchema: GenMessage<GetOIDCStatusResponse> = /*@__PURE__*/
messageDesc(file_mantrae_v1_user, 20);
messageDesc(file_mantrae_v1_user, 22);
/**
* @generated from service mantrae.v1.UserService
@@ -541,6 +558,14 @@ export const UserService: GenService<{
input: typeof LoginUserRequestSchema;
output: typeof LoginUserResponseSchema;
},
/**
* @generated from rpc mantrae.v1.UserService.LogoutUser
*/
logoutUser: {
methodKind: "unary";
input: typeof LogoutUserRequestSchema;
output: typeof LogoutUserResponseSchema;
},
/**
* @generated from rpc mantrae.v1.UserService.VerifyJWT
*/
+63 -32
View File
@@ -1,7 +1,5 @@
import { goto } from "$app/navigation";
import { logout, profileClient, useClient } from "$lib/api";
import { UserService } from "$lib/gen/mantrae/v1/user_pb";
import { token } from "$lib/stores/common";
import { profileClient, userClient } from "$lib/api";
import { profile } from "$lib/stores/profile";
import { user } from "$lib/stores/user";
import type { LayoutLoad } from "./$types";
@@ -14,45 +12,78 @@ const isPublicRoute = (path: string) => {
return path.startsWith("/login") || path === "/login";
};
export const load: LayoutLoad = async ({ url, fetch }) => {
// Case 1: No token and accessing protected route
if (!token.value && !isPublicRoute(url.pathname)) {
await goto("/login/");
user.clear();
return;
}
export const load: LayoutLoad = async ({ url }) => {
const currentPath = url.pathname;
const isPublic = isPublicRoute(currentPath);
// If we have a token, verify it
if (token.value) {
try {
const client = useClient(UserService, fetch);
const verified = await client.verifyJWT({ token: token.value });
if (!verified.user) {
throw new Error("Invalid token");
}
try {
const verified = await userClient.verifyJWT({});
if (verified.user) {
user.value = verified.user;
// Update profile if not set
if (!profile.id) {
const response = await profileClient.listProfiles({});
profile.value = response.profiles[0];
}
// Redirect to home if trying to access login page while authenticated
if (isPublicRoute(url.pathname) && user.isLoggedIn()) {
if (isPublic) {
// Authenticated user trying to access login page - redirect to home
await goto("/");
return;
}
return;
} catch (error) {
// Token verification failed, clean up
logout();
user.clear();
throw new Error("Token verification failed: " + error);
} else {
throw new Error("Authentication failed");
}
} catch (_) {
user.clear();
if (!isPublic) {
await goto("/login");
}
return;
}
// No token and trying to access protected route
if (!isPublicRoute) {
await goto("/login");
}
return;
};
// export const load: LayoutLoad = async ({ url }) => {
// // Case 1: No token and accessing protected route
// if (!token.value && !isPublicRoute(url.pathname)) {
// await goto("/login/");
// user.clear();
// return;
// }
//
// // If we have a token, verify it
// if (token.value) {
// try {
// const verified = await userClient.verifyJWT({});
// if (!verified.user) {
// throw new Error("Invalid token");
// }
// user.value = verified.user;
// if (!profile.id) {
// const response = await profileClient.listProfiles({});
// profile.value = response.profiles[0];
// }
//
// // Redirect to home if trying to access login page while authenticated
// if (isPublicRoute(url.pathname) && user.isLoggedIn()) {
// await goto("/");
// }
// return;
// } catch (error) {
// // Token verification failed, clean up
// logout();
// user.clear();
// throw new Error("Token verification failed: " + error);
// }
// }
//
// // No token and trying to access protected route
// if (!isPublicRoute) {
// await goto("/login");
// }
//
// return;
// };
+147 -145
View File
@@ -23,153 +23,155 @@
</h2>
<!-- Stats Grid -->
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Total Profiles</Card.Title>
<Origami class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await profileClient.listProfiles({}) then result}
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Connected Agents</Card.Title>
<Bot class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await agentClient.listAgents({ profileId: profile.id }) then result}
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Active DNS Provider</Card.Title>
<Globe class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await dnsClient.listDnsProviders({}) then result}
<div class="text-2xl font-bold">
{result.dnsProviders.find((p) => p.isActive)?.name || 'None'}
</div>
<p class="text-muted-foreground text-xs">
{result.totalCount} providers configured
</p>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Total Users</Card.Title>
<Users class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await userClient.listUsers({}) then result}
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
<p class="text-muted-foreground text-xs"></p>
</Card.Content>
</Card.Root>
</div>
<div class="mt-6 flex flex-row items-start gap-6">
<!-- Profile Status -->
<Card.Root class="flex-1">
<Card.Header>
<Card.Title>Profile Status</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
{#if profile.id}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Total Profiles</Card.Title>
<Origami class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await profileClient.listProfiles({}) then result}
{#each result.profiles || [] as profile (profile.id)}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<Shield class="h-4 w-4" />
<div class="space-y-1">
<p class="text-sm leading-none font-medium">
{profile.name}
</p>
<p class="text-muted-foreground text-xs">
{profile.description}
</p>
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Connected Agents</Card.Title>
<Bot class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await agentClient.listAgents({ profileId: profile.id }) then result}
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Active DNS Provider</Card.Title>
<Globe class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await dnsClient.listDnsProviders({}) then result}
<div class="text-2xl font-bold">
{result.dnsProviders.find((p) => p.isActive)?.name || 'None'}
</div>
<p class="text-muted-foreground text-xs">
{result.totalCount} providers configured
</p>
{/await}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<Card.Title class="text-sm font-medium">Total Users</Card.Title>
<Users class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
{#await userClient.listUsers({}) then result}
<div class="text-2xl font-bold">{result.totalCount}</div>
{/await}
<p class="text-muted-foreground text-xs"></p>
</Card.Content>
</Card.Root>
</div>
<div class="mt-6 flex flex-row items-start gap-6">
<!-- Profile Status -->
<Card.Root class="flex-1">
<Card.Header>
<Card.Title>Profile Status</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
{#await profileClient.listProfiles({}) then result}
{#each result.profiles || [] as profile (profile.id)}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<Shield class="h-4 w-4" />
<div class="space-y-1">
<p class="text-sm leading-none font-medium">
{profile.name}
</p>
<p class="text-muted-foreground text-xs">
{profile.description}
</p>
</div>
</div>
<div class="flex items-center gap-2">
{#await agentClient.listAgents({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Agent' : 'Agents'}
</Badge>
{/await}
{#await routerClient.listRouters({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Router' : 'Routers'}
</Badge>
{/await}
{#await serviceClient.listServices({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Service' : 'Services'}
</Badge>
{/await}
{#await middlewareClient.listMiddlewares({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Middleware' : 'Middlewares'}
</Badge>
{/await}
</div>
</div>
<div class="flex items-center gap-2">
{#await agentClient.listAgents({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Agent' : 'Agents'}
</Badge>
{/await}
{#await routerClient.listRouters({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Router' : 'Routers'}
</Badge>
{/await}
{#await serviceClient.listServices({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Service' : 'Services'}
</Badge>
{/await}
{#await middlewareClient.listMiddlewares({ profileId: profile.id }) then result}
<Badge variant={result.totalCount > 0 ? 'default' : 'secondary'}>
{result.totalCount}
{result.totalCount === 1n ? 'Middleware' : 'Middlewares'}
</Badge>
{/await}
</div>
</div>
{/each}
{/await}
</div>
</Card.Content>
</Card.Root>
{/each}
{/await}
</div>
</Card.Content>
</Card.Root>
<!-- Errors -->
<Card.Root class="flex-1">
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">
System Errors
<!-- <Button -->
<!-- variant="ghost" -->
<!-- size="icon" -->
<!-- class="rounded-full hover:bg-red-300" -->
<!-- onclick={() => errorClient.deleteErrorsByProfile({})} -->
<!-- > -->
<!-- <Trash2 /> -->
<!-- </Button> -->
</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
<!-- {#await errorClient.listErrors({}) then result} -->
<!-- {#each result.errors || [] as error (error.id)} -->
<!-- <div class="flex items-center"> -->
<!-- <div class="relative mr-4"> -->
<!-- <TriangleAlert class="h-4 w-4 text-red-500" /> -->
<!-- </div> -->
<!-- <div class="space-y-1"> -->
<!-- <p class="text-sm"> -->
<!-- {error.message} -->
<!-- </p> -->
<!-- <p class="text-muted-foreground text-sm"> -->
<!-- {error.details} -->
<!-- </p> -->
<!-- </div> -->
<!-- </div> -->
<!-- {/each} -->
<!-- {/await} -->
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Errors -->
<Card.Root class="flex-1">
<Card.Header>
<Card.Title class="flex items-center justify-between gap-2">
System Errors
<!-- <Button -->
<!-- variant="ghost" -->
<!-- size="icon" -->
<!-- class="rounded-full hover:bg-red-300" -->
<!-- onclick={() => errorClient.deleteErrorsByProfile({})} -->
<!-- > -->
<!-- <Trash2 /> -->
<!-- </Button> -->
</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
<!-- {#await errorClient.listErrors({}) then result} -->
<!-- {#each result.errors || [] as error (error.id)} -->
<!-- <div class="flex items-center"> -->
<!-- <div class="relative mr-4"> -->
<!-- <TriangleAlert class="h-4 w-4 text-red-500" /> -->
<!-- </div> -->
<!-- <div class="space-y-1"> -->
<!-- <p class="text-sm"> -->
<!-- {error.message} -->
<!-- </p> -->
<!-- <p class="text-muted-foreground text-sm"> -->
<!-- {error.details} -->
<!-- </p> -->
<!-- </div> -->
<!-- </div> -->
<!-- {/each} -->
<!-- {/await} -->
</div>
</Card.Content>
</Card.Root>
</div>
{/if}
</div>
+9 -15
View File
@@ -8,13 +8,12 @@
import PasswordInput from '$lib/components/ui/password-input/password-input.svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import { token } from '$lib/stores/common';
import { ConnectError } from '@connectrpc/connect';
import { profile } from '$lib/stores/profile';
import { handleOIDCLogin } from '$lib/api';
let username = $state('');
let password = $state('');
let remember = $state(false);
const handleReset = async () => {
if (username.length > 0) {
@@ -42,20 +41,15 @@
const isEmail = username.includes('@');
try {
const response = await userClient.loginUser({
await userClient.loginUser({
identifier: {
case: isEmail ? 'email' : 'username',
value: username
},
password: password,
remember: remember
password: password
});
token.value = response.token ?? null;
if (!token.value) {
throw new Error('No token received');
}
const verified = await userClient.verifyJWT({ token: token.value });
const verified = await userClient.verifyJWT({});
if (verified.user) {
user.value = verified.user;
if (!profile.id) {
@@ -71,9 +65,9 @@
}
};
const handleOIDCLogin = () => {
window.location.href = '/api/oidc/login';
};
// const handleOIDCLogin = () => {
// window.location.href = '/oidc/login';
// };
</script>
{#if !user.isLoggedIn()}
@@ -86,7 +80,7 @@
<form onsubmit={handleSubmit} class="p-4">
{#await userClient.getOIDCStatus({}) then value}
{#if value.loginEnabled}
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="grid gap-3">
<Label for="username">Username</Label>
<Input id="username" bind:value={username} />
@@ -121,7 +115,7 @@
{/if}
{#if value.oidcEnabled}
<Button variant="outline" class="w-full" onclick={handleOIDCLogin}>
<Button variant="outline" class="mt-3 w-full" onclick={handleOIDCLogin}>
Login with {value.provider || 'OIDC'}
</Button>
{/if}
+7 -13
View File
@@ -43,6 +43,12 @@
type: 'text',
description:
'The base URL of your backend server, including protocol (e.g., https://example.com).'
},
{
key: 'storage_select',
label: 'Storage Type',
type: 'select',
description: 'Select the storage backend for backups (e.g., local, S3).'
}
]
},
@@ -67,18 +73,6 @@
label: 'Backups to Keep',
type: 'number',
description: 'The number of recent backups to retain.'
},
{
key: 'backup_path',
label: 'Backup Path',
type: 'text',
description: 'Local filesystem path where backups will be stored.'
},
{
key: 'backup_storage_select',
label: 'Storage Type',
type: 'select',
description: 'Select the storage backend for backups (e.g., local, S3).'
}
]
},
@@ -593,7 +587,7 @@
{settingsMap[setting.key] || 'Select...'}
</Select.Trigger>
<Select.Content>
{#if setting.key === 'backup_storage_select'}
{#if setting.key === 'storage_select'}
{#each storageTypes as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}