diff --git a/.env.example b/.env.example index 408556d7..d33e5dca 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,17 @@ TVDB_PIN= # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. PUBLIC_URL=http://localhost:8901 +# See OIDC doc for help +# OIDC_GOOGLE_NAME=Google +# OIDC_GOOGLE_LOGO=https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s200 +# OIDC_GOOGLE_CLIENTID= # the client ID you got from Google +# OIDC_GOOGLE_SECRET= # the client secret you got from Google +# OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth +# OIDC_GOOGLE_TOKEN=https://oauth2.googleapis.com/token +# OIDC_GOOGLE_PROFILE=https://www.googleapis.com/oauth2/v2/userinfo +# OIDC_GOOGLE_SCOPE="email openid profile" +# OIDC_GOOGLE_AUTHMETHOD=ClientSecretPost + # Default permissions of new users. They are able to browse & play videos. # Set `verified` to true if you don't wanna manually verify users. EXTRA_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": false}' diff --git a/auth/.env.example b/auth/.env.example index adae6f80..73452a74 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -4,6 +4,11 @@ # path of the private key used to sign jwts. If this is empty, a new one will be generated on startup RSA_PRIVATE_KEY_PATH="" +PROFILE_PICTURE_PATH="/profile_pictures" + +# If true, POST /users registration is disabled and returns 403. +DISABLE_REGISTRATION=false + # json object with the claims to add to every jwt (this is read when creating a new user) EXTRA_CLAIMS='{}' # json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin). @@ -19,6 +24,19 @@ PROTECTED_CLAIMS="permissions" # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. PUBLIC_URL=http://localhost:8901 + +# See OIDC doc for help +# OIDC_GOOGLE_NAME=Google +# OIDC_GOOGLE_LOGO=https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s200 +# OIDC_GOOGLE_CLIENTID= # the client ID you got from Google +# OIDC_GOOGLE_SECRET= # the client secret you got from Google +# OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth +# OIDC_GOOGLE_TOKEN=https://oauth2.googleapis.com/token +# OIDC_GOOGLE_PROFILE=https://www.googleapis.com/oauth2/v2/userinfo +# OIDC_GOOGLE_SCOPE="email openid profile" +# OIDC_GOOGLE_AUTHMETHOD=ClientSecretPost + + # You can create apikeys at runtime via POST /key but you can also have some defined in the env. # Replace $YOURNAME with the name of the key you want (only alpha are valid) # The value will be the apikey (max 128 bytes) diff --git a/auth/config.go b/auth/config.go index 09ba344d..e610dea8 100644 --- a/auth/config.go +++ b/auth/config.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "crypto" "crypto/rand" @@ -13,6 +14,7 @@ import ( "maps" "os" "slices" + "strconv" "strings" "time" @@ -23,17 +25,19 @@ import ( ) type Configuration struct { - JwtPrivateKey *rsa.PrivateKey - JwtPublicKey *rsa.PublicKey - JwtKid string - PublicUrl string - OidcProviders map[string]OidcProviderConfig - DefaultClaims jwt.MapClaims - FirstUserClaims jwt.MapClaims - GuestClaims jwt.MapClaims - ProtectedClaims []string - ExpirationDelay time.Duration - EnvApiKeys []ApiKeyWToken + JwtPrivateKey *rsa.PrivateKey + JwtPublicKey *rsa.PublicKey + JwtKid string + PublicUrl string + OidcProviders map[string]OidcProviderConfig + DefaultClaims jwt.MapClaims + FirstUserClaims jwt.MapClaims + GuestClaims jwt.MapClaims + ProtectedClaims []string + ExpirationDelay time.Duration + EnvApiKeys []ApiKeyWToken + ProfilePicturePath string + DisableRegistration bool } type OidcAuthMethod string @@ -69,6 +73,16 @@ func LoadConfiguration(ctx context.Context, db *dbc.Queries) (*Configuration, er ret := DefaultConfig ret.PublicUrl = os.Getenv("PUBLIC_URL") + ret.ProfilePicturePath = cmp.Or( + os.Getenv("PROFILE_PICTURE_PATH"), + "/profile_pictures", + ) + + disableRegistration, err := strconv.ParseBool(cmp.Or(os.Getenv("DISABLE_REGISTRATION"), "false")) + if err != nil { + return nil, fmt.Errorf("invalid DISABLE_REGISTRATION value: %w", err) + } + ret.DisableRegistration = disableRegistration claims := os.Getenv("EXTRA_CLAIMS") if claims != "" { diff --git a/auth/logo.go b/auth/logo.go new file mode 100644 index 00000000..e9a71e0b --- /dev/null +++ b/auth/logo.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v5" + "github.com/zoriya/kyoo/keibi/dbc" +) + +const maxLogoSize = 5 << 20 + +var allowedLogoTypes = []string{ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +} + +func (h *Handler) logoPath(id uuid.UUID) string { + return filepath.Join(h.config.ProfilePicturePath, id.String()) +} + +func (h *Handler) streamManualLogo(c *echo.Context, id uuid.UUID) error { + file, err := os.Open(h.logoPath(id)) + if err != nil { + if os.IsNotExist(err) { + return echo.NewHTTPError(http.StatusNotFound, "No manual logo found") + } + return err + } + defer file.Close() + + header := make([]byte, 512) + n, err := file.Read(header) + if err != nil && err != io.EOF { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + + contentType := http.DetectContentType(header[:n]) + return c.Stream(http.StatusOK, contentType, file) +} + +func (h *Handler) writeManualLogo(id uuid.UUID, data []byte) error { + if err := os.MkdirAll(h.config.ProfilePicturePath, 0o755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(h.config.ProfilePicturePath, id.String()+"-*.tmp") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, h.logoPath(id)); err != nil { + os.Remove(tmpPath) + return err + } + return nil +} + +func (h *Handler) downloadLogo(ctx context.Context, id uuid.UUID, logoURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoURL, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected logo response status: %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxLogoSize+1)) + if err != nil { + return err + } + if len(data) > maxLogoSize { + return fmt.Errorf("logo file too large") + } + + if !slices.Contains(allowedLogoTypes, http.DetectContentType(data)) { + return fmt.Errorf("unsupported logo content type") + } + + return h.writeManualLogo(id, data) +} + +func (h *Handler) streamGravatar(c *echo.Context, email string) error { + hash := md5.Sum([]byte(strings.TrimSpace(strings.ToLower(email)))) + url := fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=404", hex.EncodeToString(hash[:])) + + req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return echo.NewHTTPError(http.StatusNotFound, "No gravatar image found for this user") + } + if resp.StatusCode != http.StatusOK { + return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + return c.Stream(http.StatusOK, contentType, resp.Body) +} + +// @Summary Get my logo +// @Description Get the current user's logo (manual upload if available, gravatar otherwise) +// @Tags users +// @Produce image/* +// @Security Jwt +// @Success 200 {file} binary +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" +// @Failure 404 {object} KError "No gravatar image found for this user" +// @Router /users/me/logo [get] +func (h *Handler) GetMyLogo(c *echo.Context) error { + ctx := c.Request().Context() + id, err := GetCurrentUserId(c) + if err != nil { + return err + } + + if err := h.streamManualLogo(c, id); err == nil { + return nil + } else if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusNotFound { + return err + } + + user, err := h.db.GetUser(ctx, dbc.GetUserParams{ + UseId: true, + Id: id, + }) + if err != nil { + return err + } + + return h.streamGravatar(c, user.User.Email) +} + +// @Summary Get user logo +// @Description Get a user's logo (manual upload if available, gravatar otherwise) +// @Tags users +// @Produce image/* +// @Security Jwt[users.read] +// @Param id path string true "The id or username of the user" +// @Success 200 {file} binary +// @Failure 404 {object} KError "No user found with id or username" +// @Failure 404 {object} KError "No gravatar image found for this user" +// @Router /users/{id}/logo [get] +func (h *Handler) GetUserLogo(c *echo.Context) error { + ctx := c.Request().Context() + err := CheckPermissions(c, []string{"users.read"}) + if err != nil { + return err + } + + id := c.Param("id") + uid, err := uuid.Parse(id) + user, err := h.db.GetUser(ctx, dbc.GetUserParams{ + UseId: err == nil, + Id: uid, + Username: id, + }) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id)) + } else if err != nil { + return err + } + + if err := h.streamManualLogo(c, user.User.Id); err == nil { + return nil + } else if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusNotFound { + return err + } + + return h.streamGravatar(c, user.User.Email) +} + +// @Summary Upload my logo +// @Description Upload a manual profile picture for the current user +// @Tags users +// @Accept multipart/form-data +// @Produce json +// @Security Jwt +// @Param logo formData file true "Profile picture image (jpeg/png/gif/webp, max 5MB)" +// @Success 204 +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" +// @Failure 413 {object} KError "File too large" +// @Failure 422 {object} KError "Missing or invalid logo file" +// @Router /users/me/logo [post] +func (h *Handler) UploadMyLogo(c *echo.Context) error { + id, err := GetCurrentUserId(c) + if err != nil { + return err + } + + fileHeader, err := c.FormFile("logo") + if err != nil { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Missing form file `logo`") + } + if fileHeader.Size > maxLogoSize { + return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "File too large") + } + + file, err := fileHeader.Open() + if err != nil { + return err + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1)) + if err != nil { + return err + } + if len(data) > maxLogoSize { + return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "File too large") + } + + if !slices.Contains(allowedLogoTypes, http.DetectContentType(data)) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "Only jpeg, png, gif or webp images are allowed") + } + + if err := h.writeManualLogo(id, data); err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} + +// @Summary Delete my logo +// @Description Delete the current user's manually uploaded profile picture +// @Tags users +// @Produce json +// @Security Jwt +// @Success 204 +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" +// @Router /users/me/logo [delete] +func (h *Handler) DeleteMyLogo(c *echo.Context) error { + id, err := GetCurrentUserId(c) + if err != nil { + return err + } + + err = os.Remove(h.logoPath(id)) + if errors.Is(err, os.ErrNotExist) { + return echo.NewHTTPError( + 404, + "User does not have a custom profile picture.", + ) + } else if err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} + +// @Summary Delete user logo +// @Description Delete the user's manually uploaded profile picture +// @Tags users +// @Produce json +// @Security Jwt +// @Success 204 +// @Param id path string true "The id or username of the user" +// @Failure 401 {object} KError "Missing jwt token" +// @Failure 403 {object} KError "Invalid jwt token (or expired)" +// @Router /users/me/{id} [delete] +func (h *Handler) DeleteUserLogo(c *echo.Context) error { + ctx := c.Request().Context() + err := CheckPermissions(c, []string{"users.write"}) + if err != nil { + return err + } + + id := c.Param("id") + uid, err := uuid.Parse(id) + user, err := h.db.GetUser(ctx, dbc.GetUserParams{ + UseId: err == nil, + Id: uid, + Username: id, + }) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id)) + } else if err != nil { + return err + } + + err = os.Remove(h.logoPath(user.User.Id)) + if errors.Is(err, os.ErrNotExist) { + return echo.NewHTTPError( + 404, + "User does not have a custom profile picture.", + ) + } else if err != nil { + return err + } + return c.NoContent(http.StatusNoContent) +} diff --git a/auth/main.go b/auth/main.go index 2271f184..e346c9c6 100644 --- a/auth/main.go +++ b/auth/main.go @@ -217,32 +217,34 @@ func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc { } } -func (h *Handler) OptionalAuthToJwt(next echo.HandlerFunc) echo.HandlerFunc { - return func(c *echo.Context) error { - ctx := c.Request().Context() +func (h *Handler) OptionalAuthToJwt(jwtMiddlware echo.MiddlewareFunc) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + ctx := c.Request().Context() - auth := c.Request().Header.Get("Authorization") - if auth == "" { - return next(c) - } + auth := c.Request().Header.Get("Authorization") + if auth == "" { + return next(c) + } - if !strings.HasPrefix(auth, "Bearer ") { - return echo.NewHTTPError(http.StatusForbidden, "Invalid bearer format") - } - token := auth[len("Bearer "):] + if !strings.HasPrefix(auth, "Bearer ") { + return echo.NewHTTPError(http.StatusForbidden, "Invalid bearer format") + } + token := auth[len("Bearer "):] - // this is only used to check if it is a session token or a jwt - _, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return next(c) - } + // this is only used to check if it is a session token or a jwt + _, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return jwtMiddlware(next)(c) + } - jwt, err := h.createJwt(ctx, token) - if err != nil { - return err + jwt, err := h.createJwt(ctx, token) + if err != nil { + return err + } + c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) + return jwtMiddlware(next)(c) } - c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) - return next(c) } } @@ -353,13 +355,15 @@ func main() { } h.config = conf + jwtMiddleware := echojwt.WithConfig(echojwt.Config{ + SigningMethod: "RS256", + SigningKey: h.config.JwtPublicKey, + }) + g := e.Group("/auth") r := e.Group("/auth") r.Use(h.TokenToJwt) - r.Use(echojwt.WithConfig(echojwt.Config{ - SigningMethod: "RS256", - SigningKey: h.config.JwtPublicKey, - })) + r.Use(jwtMiddleware) g.GET("/health", h.CheckHealth) g.GET("/ready", h.CheckReady) @@ -368,6 +372,8 @@ func main() { r.GET("/users/:id", h.GetUser) r.GET("/users/me", h.GetMe) r.GET("/users/me/logo", h.GetMyLogo) + r.POST("/users/me/logo", h.UploadMyLogo) + r.DELETE("/users/me/logo", h.DeleteMyLogo) r.GET("/users/:id/logo", h.GetUserLogo) r.DELETE("/users/:id", h.DeleteUser) r.DELETE("/users/me", h.DeleteSelf) @@ -381,17 +387,14 @@ func main() { r.DELETE("/sessions", h.Logout) r.DELETE("/sessions/:id", h.Logout) r.GET("/users/:id/sessions", h.ListUserSessions) + r.GET("/users/me/sessions", h.ListMySessions) g.GET("/oidc/login/:provider", h.OidcLogin) r.DELETE("/oidc/login/:provider", h.OidcUnlink) g.GET("/oidc/logged/:provider", h.OidcLogged) or := e.Group("/auth") - or.Use(h.OptionalAuthToJwt) - or.Use(echojwt.WithConfig(echojwt.Config{ - SigningMethod: "RS256", - SigningKey: h.config.JwtPublicKey, - })) + or.Use(h.OptionalAuthToJwt(jwtMiddleware)) or.GET("/oidc/callback/:provider", h.OidcCallback) r.GET("/keys", h.ListApiKey) diff --git a/auth/oidc.go b/auth/oidc.go index a6b6aa17..619cb2b9 100644 --- a/auth/oidc.go +++ b/auth/oidc.go @@ -265,6 +265,9 @@ type RawProfile struct { Uid *string `json:"uid"` Id *string `json:"id"` Guid *string `json:"guid"` + Picture *string `json:"picture"` + AvatarURL *string `json:"avatar_url"` + Avatar *string `json:"avatar"` Username *string `json:"username"` PreferredUsername *string `json:"preferred_username"` Login *string `json:"login"` @@ -276,9 +279,10 @@ type RawProfile struct { } type Profile struct { - Sub string `json:"sub,omitempty"` - Username string `json:"username,omitempty"` - Email string `json:"email,omitempty"` + Sub string `json:"sub,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + PictureURL string `json:"pictureUrl,omitempty"` } func (h *Handler) fetchOidcProfile(c *echo.Context, provider OidcProviderConfig, accessToken string) (Profile, error) { @@ -316,6 +320,25 @@ func (h *Handler) fetchOidcProfile(c *echo.Context, provider OidcProviderConfig, if sub == nil { return Profile{}, echo.NewHTTPError(http.StatusInternalServerError, "Missing sub or username") } + picture := cmp.Or(profile.Picture, profile.AvatarURL, profile.Avatar) + if picture == nil { + if rawPicture, ok := profile.Account["picture"]; ok { + if pictureURL, ok := rawPicture.(string); ok { + picture = &pictureURL + } + } + } + if picture == nil { + if rawPicture, ok := profile.User["picture"]; ok { + if pictureURL, ok := rawPicture.(string); ok { + picture = &pictureURL + } + } + } + pictureURL := "" + if picture != nil { + pictureURL = *picture + } return Profile{ Sub: *sub, Username: *cmp.Or( @@ -331,6 +354,7 @@ func (h *Handler) fetchOidcProfile(c *echo.Context, provider OidcProviderConfig, *sub, provider, ))), + PictureURL: pictureURL, }, nil } @@ -421,6 +445,24 @@ func (h *Handler) CreateUserByOidc( if ErrIs(err, pgerrcode.UniqueViolation) { return echo.NewHTTPError(http.StatusConflict, "A user already exists with the same username or email. If this is you, login via username and then link your account.") } + if err != nil { + return err + } + + if profile.PictureURL != "" { + if err := h.downloadLogo(ctx, user.Id, profile.PictureURL); err != nil { + slog.Warn( + "Could not download OIDC profile picture", + "provider", + provider.Id, + "sub", + profile.Sub, + "err", + err, + ) + } + } + } var expireAt *time.Time @@ -488,8 +530,9 @@ func (h *Handler) OidcUnlink(c *echo.Context) error { } type ServerInfo struct { - PublicUrl string `json:"publicUrl"` - Oidc map[string]OidcInfo `json:"oidc"` + PublicUrl string `json:"publicUrl"` + AllowRegister bool `json:"allowRegister"` + Oidc map[string]OidcInfo `json:"oidc"` } type OidcInfo struct { @@ -505,8 +548,9 @@ type OidcInfo struct { // @Router /info [get] func (h *Handler) Info(c *echo.Context) error { ret := ServerInfo{ - PublicUrl: h.config.PublicUrl, - Oidc: make(map[string]OidcInfo), + PublicUrl: h.config.PublicUrl, + AllowRegister: !h.config.DisableRegistration, + Oidc: make(map[string]OidcInfo), } for _, provider := range h.config.OidcProviders { ret.Oidc[provider.Id] = OidcInfo{ diff --git a/auth/sessions.go b/auth/sessions.go index 97cbc257..c9c2737e 100644 --- a/auth/sessions.go +++ b/auth/sessions.go @@ -34,6 +34,11 @@ type SessionWToken struct { Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="` } +type SessionWCurrent struct { + Session + Current bool `json:"current"` +} + func MapSession(ses *dbc.Session) Session { dev := ses.Device if ses.Device != nil { @@ -143,7 +148,7 @@ func (h *Handler) createSession(c *echo.Context, user *User) error { // @Tags sessions // @Produce json // @Security Jwt -// @Success 200 {array} Session +// @Success 200 {array} SessionWCurrent // @Failure 401 {object} KError "Missing jwt token" // @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Router /sessions [get] @@ -167,9 +172,14 @@ func (h *Handler) ListMySessions(c *echo.Context) error { return err } - ret := make([]Session, 0, len(dbSessions)) + sid, _ := GetCurrentSessionId(c) + + ret := make([]SessionWCurrent, 0, len(dbSessions)) for _, ses := range dbSessions { - ret = append(ret, MapSession(&ses)) + ret = append(ret, SessionWCurrent{ + Session: MapSession(&ses), + Current: ses.Id == sid, + }) } return c.JSON(http.StatusOK, ret) @@ -199,9 +209,6 @@ func (h *Handler) ListUserSessions(c *echo.Context) error { Id: uid, Username: id, }) - if err != nil { - return err - } if err == pgx.ErrNoRows { return echo.NewHTTPError(http.StatusNotFound, "No user found with id or username") } else if err != nil { diff --git a/auth/users.go b/auth/users.go index 5a0ff5f2..194dd38f 100644 --- a/auth/users.go +++ b/auth/users.go @@ -1,12 +1,9 @@ package main import ( - "crypto/md5" - "encoding/hex" "fmt" "net/http" "strconv" - "strings" "github.com/alexedwards/argon2id" "github.com/google/uuid" @@ -153,97 +150,6 @@ func (h *Handler) GetMe(c *echo.Context) error { return c.JSON(200, ret) } -func (h *Handler) streamGravatar(c *echo.Context, email string) error { - hash := md5.Sum([]byte(strings.TrimSpace(strings.ToLower(email)))) - url := fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=404", hex.EncodeToString(hash[:])) - - req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return echo.NewHTTPError(http.StatusNotFound, "No gravatar image found for this user") - } - if resp.StatusCode != http.StatusOK { - return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") - } - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/octet-stream" - } - - return c.Stream(http.StatusOK, contentType, resp.Body) -} - -// @Summary Get my logo -// @Description Get the current user's gravatar image -// @Tags users -// @Produce image/* -// @Security Jwt -// @Success 200 {file} binary -// @Failure 401 {object} KError "Missing jwt token" -// @Failure 403 {object} KError "Invalid jwt token (or expired)" -// @Failure 404 {object} KError "No gravatar image found for this user" -// @Router /users/me/logo [get] -func (h *Handler) GetMyLogo(c *echo.Context) error { - ctx := c.Request().Context() - id, err := GetCurrentUserId(c) - if err != nil { - return err - } - - users, err := h.db.GetUser(ctx, dbc.GetUserParams{ - UseId: true, - Id: id, - }) - if err != nil { - return err - } - - return h.streamGravatar(c, users.User.Email) -} - -// @Summary Get user logo -// @Description Get a user's gravatar image -// @Tags users -// @Produce image/* -// @Security Jwt[users.read] -// @Param id path string true "The id or username of the user" -// @Success 200 {file} binary -// @Failure 404 {object} KError "No user found with id or username" -// @Failure 404 {object} KError "No gravatar image found for this user" -// @Router /users/{id}/logo [get] -func (h *Handler) GetUserLogo(c *echo.Context) error { - ctx := c.Request().Context() - err := CheckPermissions(c, []string{"users.read"}) - if err != nil { - return err - } - - id := c.Param("id") - uid, err := uuid.Parse(id) - users, err := h.db.GetUser(ctx, dbc.GetUserParams{ - UseId: err == nil, - Id: uid, - Username: id, - }) - if err == pgx.ErrNoRows { - return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id)) - } else if err != nil { - return err - } - - return h.streamGravatar(c, users.User.Email) -} - // @Summary Register // @Description Register as a new user and open a session for it // @Tags users @@ -253,9 +159,14 @@ func (h *Handler) GetUserLogo(c *echo.Context) error { // @Param user body RegisterDto false "Registration informations" // @Success 201 {object} SessionWToken // @Success 409 {object} KError "Duplicated email or username" +// @Failure 403 {object} KError "Registrations are disabled" // @Failure 422 {object} KError "Invalid register body" // @Router /users [post] func (h *Handler) Register(c *echo.Context) error { + if h.config.DisableRegistration { + return echo.NewHTTPError(http.StatusForbidden, "Registrations are disabled") + } + ctx := c.Request().Context() var req RegisterDto err := c.Bind(&req) diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 23186793..1f5be66a 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -37,6 +37,13 @@ Create kyoo auth name {{- printf "%s-%s" (include "kyoo.fullname" .) .Values.auth.name | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +Create kyoo auth-profile-pictures name +*/}} +{{- define "kyoo.authprofilepictures.fullname" -}} +{{- printf "%s-%s%s" (include "kyoo.fullname" .) .Values.auth.name "profile-pictures" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + {{/* Create the name of the auth service account to use */}} @@ -142,4 +149,4 @@ Create kyoo postgres base host */}} {{- define "kyoo.postgres.shared.host" -}} {{- default (printf "%s-postgres" (include "kyoo.fullname" .)) .Values.global.postgres.shared.host -}} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/chart/templates/auth/deployment.yaml b/chart/templates/auth/deployment.yaml index 29fa5e2f..61c33e7f 100644 --- a/chart/templates/auth/deployment.yaml +++ b/chart/templates/auth/deployment.yaml @@ -157,8 +157,12 @@ spec: securityContext: {{- toYaml . | nindent 12 }} {{- end }} - {{- if or .Values.global.extraVolumeMounts .Values.auth.kyoo_auth.extraVolumeMounts .Values.kyoo.auth.privatekey.existingSecret }} + {{- if or .Values.global.extraVolumeMounts .Values.auth.kyoo_auth.extraVolumeMounts .Values.kyoo.auth.privatekey.existingSecret .Values.auth.persistence.enabled }} volumeMounts: + {{- if .Values.auth.persistence.enabled }} + - name: profilepictures + mountPath: /profile_pictures + {{- end }} {{- with .Values.global.extraVolumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -178,8 +182,19 @@ spec: initContainers: {{- tpl (toYaml .) $ | nindent 6 }} {{- end }} - {{- if or .Values.global.extraVolumes .Values.auth.extraVolumes .Values.kyoo.auth.privatekey.existingSecret }} + {{- if or .Values.global.extraVolumes .Values.auth.extraVolumes .Values.kyoo.auth.privatekey.existingSecret .Values.auth.persistence.enabled }} volumes: + {{- if .Values.auth.persistence.enabled }} + {{- if .Values.auth.persistence.existingClaim }} + - name: profilepictures + persistentVolumeClaim: + claimName: {{ .Values.auth.persistence.existingClaim }} + {{- else }} + - name: profilepictures + persistentVolumeClaim: + claimName: {{ include "kyoo.authprofilepictures.fullname" . }} + {{- end }} + {{- end }} {{- with .Values.global.extraVolumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/chart/templates/auth/pvc.yaml b/chart/templates/auth/pvc.yaml new file mode 100644 index 00000000..bf55fcd5 --- /dev/null +++ b/chart/templates/auth/pvc.yaml @@ -0,0 +1,26 @@ +{{- if and .Values.auth.persistence.enabled (not .Values.auth.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "kyoo.authprofilepictures.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kyoo.labels" (dict "context" . "component" .Values.auth.name "name" .Values.auth.name) | nindent 4 }} + {{- with (mergeOverwrite (deepCopy .Values.global.persistentVolumeClaimAnnotations) .Values.auth.persistence.annotations) }} + annotations: + {{- range $key, $value := . }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + accessModes: + {{- range .Values.auth.persistence.accessModes }} + - {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.auth.persistence.size }} + {{- if .Values.auth.persistence.storageClass }} + storageClassName: {{ .Values.auth.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index eb4d919d..c8be039e 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -278,6 +278,15 @@ auth: extraContainers: [] extraInitContainers: [] extraVolumes: [] + # profile pictures of users + persistence: + enabled: true + size: 500Mi + annotations: {} + storageClass: "" + accessModes: + - ReadWriteOnce + existingClaim: "" # front deployment configuration front: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fa3124e6..a6fc042e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -64,6 +64,8 @@ services: - "4568:4568" env_file: - ./.env + volumes: + - profile_pictures:/profile_pictures labels: - "traefik.enable=true" - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)" @@ -213,4 +215,5 @@ services: volumes: db: images: + profile_pictures: transcoder_metadata: diff --git a/docker-compose.yml b/docker-compose.yml index 8297667d..626929f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,8 @@ services: condition: service_healthy env_file: - ./.env + volumes: + - profile_pictures:/profile_pictures labels: - "traefik.enable=true" - "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)" @@ -159,4 +161,5 @@ services: volumes: db: images: + profile_pictures: transcoder_metadata: diff --git a/front/app.config.ts b/front/app.config.ts index dcf3e2c7..da47565a 100644 --- a/front/app.config.ts +++ b/front/app.config.ts @@ -107,6 +107,13 @@ export const expo: ExpoConfig = { }, }, ], + [ + "expo-image-picker", + { + cameraPermission: false, + microphonePermission: false, + }, + ], ], experiments: { typedRoutes: true, diff --git a/front/bun.lock b/front/bun.lock index f0689d12..fc4a27d0 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -27,6 +27,7 @@ "expo-dev-client": "~55.0.18", "expo-font": "^55.0.4", "expo-image": "~55.0.6", + "expo-image-picker": "^55.0.13", "expo-linear-gradient": "~55.0.9", "expo-linking": "~55.0.8", "expo-localization": "~55.0.9", @@ -901,6 +902,10 @@ "expo-image": ["expo-image@55.0.6", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TKuu0uBmgTZlhd91Glv+V4vSBMlfl0bdQxfl97oKKZUo3OBC13l3eLik7v3VNLJN7PZbiwOAiXkZkqSOBx/Xsw=="], + "expo-image-loader": ["expo-image-loader@55.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ=="], + + "expo-image-picker": ["expo-image-picker@55.0.13", "", { "dependencies": { "expo-image-loader": "~55.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-G+W11rcoUi3rK+6cnKWkTfZilMkGVZnYe90TiM3R98nPSlzGBoto3a/TkGGTJXedz/dmMzr49L+STlWhuKKIFw=="], + "expo-json-utils": ["expo-json-utils@55.0.0", "", {}, "sha512-aupt/o5PDAb8dXDCb0JcRdkqnTLxe/F+La7jrnyd/sXlYFfRgBJLFOa1SqVFXm1E/Xam1SE/yw6eAb+DGY7Arg=="], "expo-keep-awake": ["expo-keep-awake@55.0.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg=="], diff --git a/front/package.json b/front/package.json index 27ec69cc..b4fd28b6 100644 --- a/front/package.json +++ b/front/package.json @@ -38,6 +38,7 @@ "expo-dev-client": "~55.0.18", "expo-font": "^55.0.4", "expo-image": "~55.0.6", + "expo-image-picker": "^55.0.13", "expo-linear-gradient": "~55.0.9", "expo-linking": "~55.0.8", "expo-localization": "~55.0.9", @@ -68,7 +69,6 @@ "react-native-worklets": "0.7.2", "react-tooltip": "^5.30.0", "react-use-websocket": "^4.13.0", - "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", diff --git a/front/public/translations/en.json b/front/public/translations/en.json index d2314aec..7167f49d 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -132,7 +132,7 @@ "download": "Download", "search": "Search", "login": "Login", - "admin": "Admin panel" + "admin": "Admin" }, "settings": { "general": { @@ -184,6 +184,12 @@ "newPassword": "New password" } }, + "sessions": { + "label": "Sessions", + "description": "Created {{createdDate}} - Last used {{lastUsed}}", + "current": "Current session", + "revoke": "Revoke" + }, "oidc": { "label": "Linked accounts", "connected": "Connected as {{username}}.", @@ -244,7 +250,9 @@ "or-login": "Have an account already? <1>Log in.", "password-no-match": "Passwords do not match.", "delete": "Delete your account", - "delete-confirmation": "This action can't be reverted. Are you sure?" + "delete-confirmation": "This action can't be reverted. Are you sure?", + "register-disabled": "Registrations are disabled.", + "register-disabled-oidc": "Password registration is disabled. Use OIDC." }, "downloads": { "empty": "Nothing downloaded yet, start browsing for something you like", @@ -297,7 +305,12 @@ "set-permissions": "Set permissions", "delete": "Delete user", "unverifed": "Unverifed", - "verify": "Verify user" + "verify": "Verify user", + "table": { + "username": "Username", + "lastSeen": "Last seen", + "oidc": "OIDC" + } }, "scanner": { "label": "Scanner", diff --git a/front/src/app/(app)/admin/add.tsx b/front/src/app/(app)/admin/add.tsx index 5cec8acd..9ec8f3b3 100644 --- a/front/src/app/(app)/admin/add.tsx +++ b/front/src/app/(app)/admin/add.tsx @@ -1,3 +1,5 @@ import { AddPage } from "~/ui/admin/add"; +export { ErrorBoundary } from "~/ui/error-boundary"; + export default AddPage; diff --git a/front/src/app/(app)/admin/match/[id].tsx b/front/src/app/(app)/admin/match/[id].tsx index c17804d2..024e9bd8 100644 --- a/front/src/app/(app)/admin/match/[id].tsx +++ b/front/src/app/(app)/admin/match/[id].tsx @@ -1,3 +1,5 @@ import { MatchPage } from "~/ui/admin/match"; +export { ErrorBoundary } from "~/ui/error-boundary"; + export default MatchPage; diff --git a/front/src/app/(app)/admin/users.tsx b/front/src/app/(app)/admin/users.tsx new file mode 100644 index 00000000..f33355a0 --- /dev/null +++ b/front/src/app/(app)/admin/users.tsx @@ -0,0 +1,5 @@ +import { AdminUsersPage } from "~/ui/admin/users"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default AdminUsersPage; diff --git a/front/src/models/auth-info.ts b/front/src/models/auth-info.ts new file mode 100644 index 00000000..c265d6a3 --- /dev/null +++ b/front/src/models/auth-info.ts @@ -0,0 +1,32 @@ +import { Platform } from "react-native"; +import z from "zod/v4"; + +export const AuthInfo = z + .object({ + publicUrl: z.string(), + allowRegister: z.boolean().optional().default(true), + oidc: z.record( + z.string(), + z.object({ + name: z.string(), + logo: z.string().nullable().optional(), + }), + ), + }) + .transform((x) => { + const redirect = `${Platform.OS === "web" ? x.publicUrl : "kyoo://"}/oidc-callback?apiUrl=${x.publicUrl}`; + return { + ...x, + oidc: Object.fromEntries( + Object.entries(x.oidc).map(([provider, info]) => [ + provider, + { + ...info, + connect: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(redirect)}`, + link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(`${redirect}&link=true`)}`, + }, + ]), + ), + }; + }); +export type AuthInfo = z.infer; diff --git a/front/src/models/user.ts b/front/src/models/user.ts index 495f127d..eb3c68f9 100644 --- a/front/src/models/user.ts +++ b/front/src/models/user.ts @@ -6,7 +6,10 @@ export const User = z username: z.string(), email: z.string(), hasPassword: z.boolean().default(true), + createdDate: z.coerce.date().default(new Date()), + lastSeen: z.coerce.date().default(new Date()), claims: z.object({ + verified: z.boolean().default(true), permissions: z.array(z.string()), settings: z .object({ @@ -45,9 +48,8 @@ export const User = z }) .transform((x) => ({ ...x, - logo: `auth/users/${x.id}/logo`, - // isVerified: x.permissions.length > 0, - isAdmin: true, //x.permissions?.includes("admin.write"), + logo: `/auth/users/${x.id}/logo`, + isAdmin: x.claims.permissions.includes("users.write"), })); export type User = z.infer; diff --git a/front/src/primitives/avatar.tsx b/front/src/primitives/avatar.tsx index 45a1766e..1c7399ef 100644 --- a/front/src/primitives/avatar.tsx +++ b/front/src/primitives/avatar.tsx @@ -58,7 +58,7 @@ export const Avatar = ({ resizeMode="cover" source={{ uri: src }} alt={alt} - className="absolute inset-0" + className="absolute inset-0 bg-slate-200 dark:bg-slate-200" /> )} {!src && !placeholder && ( diff --git a/front/src/primitives/divider.tsx b/front/src/primitives/divider.tsx index dacaced4..f5803613 100644 --- a/front/src/primitives/divider.tsx +++ b/front/src/primitives/divider.tsx @@ -1,5 +1,7 @@ import { HR as EHR } from "@expo/html-elements"; +import { View } from "react-native"; import { cn } from "~/utils"; +import { P } from "./text"; export const HR = ({ orientation = "horizontal", @@ -21,3 +23,13 @@ export const HR = ({ /> ); }; + +export const HRP = ({ text }: { text: string }) => { + return ( + +
+

{text}

+
+
+ ); +}; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index 14ec37eb..c41ae2af 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -41,17 +41,12 @@ export const queryFn = async (context: { try { resp = await fetch(context.url, { method: context.method, - body: - "body" in context && context.body - ? JSON.stringify(context.body) - : "formData" in context && context.formData - ? context.formData - : undefined, + body: context.body ? JSON.stringify(context.body) : context.formData, headers: { ...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}), - ...("body" in context ? { "Content-Type": "application/json" } : {}), + ...(context.body ? { "Content-Type": "application/json" } : {}), }, signal: context.signal, }); @@ -319,6 +314,7 @@ type MutationParams = { [query: string]: boolean | number | string | string[] | undefined; }; body?: object; + formData?: FormData; }; export const useMutation = ({ @@ -337,7 +333,7 @@ export const useMutation = ({ const queryClient = useQueryClient(); const mutation = useRQMutation({ mutationFn: (param: T) => { - const { method, path, params, body } = { + const { method, path, params, body, formData } = { ...queryParams, ...compute?.(param), } as Required; @@ -346,6 +342,7 @@ export const useMutation = ({ method, url: keyToUrl(toQueryKey({ apiUrl, path, params })), body, + formData, authToken, parser: null, }); diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx index d75d2289..2b887389 100644 --- a/front/src/ui/admin/index.tsx +++ b/front/src/ui/admin/index.tsx @@ -1 +1,2 @@ +export * from "./users"; export * from "./videos-modal"; diff --git a/front/src/ui/admin/users.tsx b/front/src/ui/admin/users.tsx new file mode 100644 index 00000000..6f04d998 --- /dev/null +++ b/front/src/ui/admin/users.tsx @@ -0,0 +1,244 @@ +import Admin from "@material-symbols/svg-400/rounded/admin_panel_settings.svg"; +import Check from "@material-symbols/svg-400/rounded/check-fill.svg"; +import Close from "@material-symbols/svg-400/rounded/close-fill.svg"; +import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { User } from "~/models"; +import { AuthInfo } from "~/models/auth-info"; +import { + Avatar, + Container, + HR, + Icon, + IconButton, + Menu, + P, + Skeleton, + SubP, + tooltip, +} from "~/primitives"; +import { + InfiniteFetch, + type QueryIdentifier, + useFetch, + useMutation, +} from "~/query"; +import { cn } from "~/utils"; + +const formatLastSeen = (date: Date) => { + return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; +}; + +const UserRow = ({ + id, + logo, + username, + lastSeen, + oidc, + oidcInfo, + isVerified, + isAdmin, +}: { + id: string; + logo: string; + username: string; + lastSeen: Date; + oidc: User["oidc"]; + oidcInfo?: AuthInfo["oidc"]; + isVerified: boolean; + isAdmin: boolean; +}) => { + const { t } = useTranslation(); + const oidcProviders = Object.keys(oidc); + + const { mutateAsync } = useMutation({ + path: ["auth", "users", id], + compute: (action: "verify" | "admin" | "delete") => ({ + method: action === "delete" ? "DELETE" : "PATCH", + body: { + claims: + action === "verify" + ? { verified: true } + : { + permissions: [ + "users.read", + "users.write", + "users.delete", + "apikeys.read", + "apikeys.write", + "core.read", + "core.write", + "core.play", + "scanner.trigger", + "scanner.guess", + "scanner.search", + "scanner.add", + ], + }, + }, + }), + invalidate: ["auth", "users"], + }); + + return ( + + + +

+ {username} +

+ {formatLastSeen(lastSeen)} +
+ + {formatLastSeen(lastSeen)} + + + {oidcProviders.length === 0 ? ( + - + ) : ( + oidcProviders.map((provider) => ( + + )) + )} + + + + {!isVerified && ( + await mutateAsync("verify")} + /> + )} + await mutateAsync("admin")} + /> +
+ await mutateAsync("delete")} + /> +
+
+ ); +}; + +UserRow.Loader = () => { + return ( + + + + + + + + + + + + ); +}; + +const UsersHeader = () => { + const { t } = useTranslation(); + + return ( + + + + + {t("admin.users.table.username")} + + + {t("admin.users.table.lastSeen")} + + + {t("admin.users.table.oidc")} + + + +
+
+ ); +}; + +export const AdminUsersPage = () => { + const { data } = useFetch(AdminUsersPage.authQuery()); + + return ( + + + + +
+ } + Render={({ item }) => ( + + + + )} + Loader={() => ( + + + + )} + Divider={() => ( + +
+
+ )} + /> + ); +}; + +AdminUsersPage.query = (): QueryIdentifier => ({ + parser: User, + path: ["auth", "users"], + infinite: true, +}); + +AdminUsersPage.authQuery = (): QueryIdentifier => ({ + parser: AuthInfo, + path: ["auth", "info"], +}); diff --git a/front/src/ui/login/login.tsx b/front/src/ui/login/login.tsx index 8dfcbfd3..a6ef3bf4 100644 --- a/front/src/ui/login/login.tsx +++ b/front/src/ui/login/login.tsx @@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { A, Button, H1, Input, P } from "~/primitives"; import { defaultApiUrl } from "~/providers/account-provider"; +import { useFetch } from "~/query"; import { useQueryState } from "~/utils"; import { FormPage } from "./form"; import { login } from "./logic"; @@ -20,46 +21,48 @@ export const LoginPage = () => { const { t } = useTranslation(); const router = useRouter(); + const { data: info } = useFetch(OidcLogin.query(apiUrl)); if (Platform.OS !== "web" && !apiUrl) return ; return (

{t("login.login")}

- -

{t("login.username")}

- setUsername(value)} - autoCapitalize="none" - /> -

{t("login.password")}

- setPassword(value)} - /> - {error &&

{error}

} - ))}
- {or} + )} /> @@ -81,33 +57,3 @@ OidcLogin.query = (apiUrl?: string): QueryIdentifier => ({ parser: AuthInfo, options: { apiUrl }, }); - -const AuthInfo = z - .object({ - publicUrl: z.string(), - allowRegister: z.boolean().optional().default(true), - oidc: z.record( - z.string(), - z.object({ - name: z.string(), - logo: z.string().nullable().optional(), - }), - ), - }) - .transform((x) => { - const redirect = `${Platform.OS === "web" ? x.publicUrl : "kyoo://"}/oidc-callback?apiUrl=${x.publicUrl}`; - return { - ...x, - oidc: Object.fromEntries( - Object.entries(x.oidc).map(([provider, info]) => [ - provider, - { - ...info, - connect: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(redirect)}`, - link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(`${redirect}&link=true`)}`, - }, - ]), - ), - }; - }); -type AuthInfo = z.infer; diff --git a/front/src/ui/login/register.tsx b/front/src/ui/login/register.tsx index 5da31a75..a14c1448 100644 --- a/front/src/ui/login/register.tsx +++ b/front/src/ui/login/register.tsx @@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { A, Button, H1, Input, P } from "~/primitives"; import { defaultApiUrl } from "~/providers/account-provider"; +import { useFetch } from "~/query"; import { useQueryState } from "~/utils"; import { FormPage } from "./form"; import { login } from "./logic"; @@ -21,63 +22,84 @@ export const RegisterPage = () => { const router = useRouter(); const { t } = useTranslation(); + const { data: info } = useFetch(OidcLogin.query(apiUrl)); if (Platform.OS !== "web" && !apiUrl) return ; - - return ( - -

{t("login.register")}

- -

{t("login.username")}

- setUsername(value)} - /> - -

{t("login.email")}

- setEmail(value)} /> - -

{t("login.password")}

- setPassword(value)} - /> - -

{t("login.confirm")}

- setConfirm(value)} - /> - - {password !== confirm && ( -

- {t("login.password-no-match")} -

- )} - {error &&

{error}

} -