Auth missing features (#1394)

This commit is contained in:
Zoe Roux 2026-03-26 23:48:09 +01:00 committed by GitHub
commit 01548fbb2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1153 additions and 366 deletions

View File

@ -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=<client-id> # the client ID you got from Google
# OIDC_GOOGLE_SECRET=<client-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}'

View File

@ -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=<client-id> # the client ID you got from Google
# OIDC_GOOGLE_SECRET=<client-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)

View File

@ -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 != "" {

337
auth/logo.go Normal file
View File

@ -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)
}

View File

@ -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)

View File

@ -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{

View File

@ -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 {

View File

@ -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)

View File

@ -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 -}}
{{- end -}}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -107,6 +107,13 @@ export const expo: ExpoConfig = {
},
},
],
[
"expo-image-picker",
{
cameraPermission: false,
microphonePermission: false,
},
],
],
experiments: {
typedRoutes: true,

View File

@ -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=="],

View File

@ -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",

View File

@ -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</1>.",
"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",

View File

@ -1,3 +1,5 @@
import { AddPage } from "~/ui/admin/add";
export { ErrorBoundary } from "~/ui/error-boundary";
export default AddPage;

View File

@ -1,3 +1,5 @@
import { MatchPage } from "~/ui/admin/match";
export { ErrorBoundary } from "~/ui/error-boundary";
export default MatchPage;

View File

@ -0,0 +1,5 @@
import { AdminUsersPage } from "~/ui/admin/users";
export { ErrorBoundary } from "~/ui/error-boundary";
export default AdminUsersPage;

View File

@ -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<typeof AuthInfo>;

View File

@ -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<typeof User>;

View File

@ -58,7 +58,7 @@ export const Avatar = <AsProps = ViewProps>({
resizeMode="cover"
source={{ uri: src }}
alt={alt}
className="absolute inset-0"
className="absolute inset-0 bg-slate-200 dark:bg-slate-200"
/>
)}
{!src && !placeholder && (

View File

@ -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 (
<View className="my-2 w-full flex-row items-center">
<HR className="grow" />
<P className="uppercase">{text}</P>
<HR className="grow" />
</View>
);
};

View File

@ -41,17 +41,12 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(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 = <T = void, QueryRet = void>({
@ -337,7 +333,7 @@ export const useMutation = <T = void, QueryRet = void>({
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<MutationParams>;
@ -346,6 +342,7 @@ export const useMutation = <T = void, QueryRet = void>({
method,
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
body,
formData,
authToken,
parser: null,
});

View File

@ -1 +1,2 @@
export * from "./users";
export * from "./videos-modal";

View File

@ -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 (
<View className="flex-row items-center gap-4 px-3">
<Avatar src={logo} placeholder={username} className="h-8 w-8" />
<View className="min-w-0 flex-1">
<P
numberOfLines={1}
className="font-semibold text-slate-900 dark:text-slate-200"
>
{username}
</P>
<SubP className="sm:hidden">{formatLastSeen(lastSeen)}</SubP>
</View>
<SubP className="hidden w-45 shrink-0 text-right sm:flex">
{formatLastSeen(lastSeen)}
</SubP>
<View className="w-20 shrink-0 flex-row justify-end gap-1">
{oidcProviders.length === 0 ? (
<SubP>-</SubP>
) : (
oidcProviders.map((provider) => (
<Avatar
key={provider}
src={oidcInfo?.[provider]?.logo ?? undefined}
placeholder={provider}
{...tooltip(oidcInfo?.[provider]?.name ?? provider)}
/>
))
)}
</View>
<Icon
icon={isAdmin ? Admin : isVerified ? Check : Close}
className={cn(
"fill-amber-500 dark:fill-amber-500",
isVerified && "fill-emerald-500 dark:fill-emerald-500",
isAdmin && "fill-accent dark:fill-accent",
)}
{...tooltip(
t(
isAdmin
? "admin.users.adminUser"
: isVerified
? "admin.users.regularUser"
: "admin.users.unverifed",
),
)}
/>
<Menu Trigger={IconButton} icon={MoreVert}>
{!isVerified && (
<Menu.Item
label={t("admin.users.verify")}
icon={Check}
onSelect={async () => await mutateAsync("verify")}
/>
)}
<Menu.Item
label={t("admin.users.set-permissions")}
icon={Admin}
onSelect={async () => await mutateAsync("admin")}
/>
<HR />
<Menu.Item
label={t("admin.users.delete")}
icon={Close}
onSelect={async () => await mutateAsync("delete")}
/>
</Menu>
</View>
);
};
UserRow.Loader = () => {
return (
<View className="flex-row items-center gap-4 px-3 py-2">
<Avatar.Loader className="h-8 w-8" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="hidden h-4 w-50 sm:flex" />
<View className="w-20 flex-row gap-1">
<Avatar.Loader />
<Avatar.Loader />
</View>
<Skeleton className="h-6 w-6" />
<Icon icon={MoreVert} />
</View>
);
};
const UsersHeader = () => {
const { t } = useTranslation();
return (
<View className="mt-4 px-3 pt-4 pb-1">
<View className="flex-row items-center gap-4 pb-2">
<View className="w-8" />
<SubP className="flex-1 font-semibold uppercase">
{t("admin.users.table.username")}
</SubP>
<SubP className="hidden w-40 shrink-0 text-right font-semibold uppercase sm:flex">
{t("admin.users.table.lastSeen")}
</SubP>
<SubP className="w-20 shrink-0 text-right font-semibold uppercase">
{t("admin.users.table.oidc")}
</SubP>
<View className="w-22" />
</View>
<HR />
</View>
);
};
export const AdminUsersPage = () => {
const { data } = useFetch(AdminUsersPage.authQuery());
return (
<InfiniteFetch
query={AdminUsersPage.query()}
layout={{
layout: "vertical",
numColumns: 1,
size: 76,
gap: 8,
}}
Header={
<View>
<Container>
<UsersHeader />
</Container>
</View>
}
Render={({ item }) => (
<Container>
<UserRow
{...item}
oidcInfo={data?.oidc}
isVerified={item.claims.verified}
/>
</Container>
)}
Loader={() => (
<Container>
<UserRow.Loader />
</Container>
)}
Divider={() => (
<Container>
<HR />
</Container>
)}
/>
);
};
AdminUsersPage.query = (): QueryIdentifier<User> => ({
parser: User,
path: ["auth", "users"],
infinite: true,
});
AdminUsersPage.authQuery = (): QueryIdentifier<AuthInfo> => ({
parser: AuthInfo,
path: ["auth", "info"],
});

View File

@ -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 <ServerUrlPage />;
return (
<FormPage apiUrl={apiUrl!}>
<H1 className="pb-4">{t("login.login")}</H1>
<OidcLogin apiUrl={apiUrl} error={error}>
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="password"
onChangeText={(value) => setPassword(value)}
/>
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
const { error } = await login("login", {
login: username,
password,
apiUrl,
});
setError(error);
if (error) return;
router.replace("/");
}}
className="m-2 my-6 w-60 self-center"
/>
<OidcLogin apiUrl={apiUrl} />
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="password"
onChangeText={(value) => setPassword(value)}
/>
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
const { error } = await login("login", {
login: username,
password,
apiUrl,
});
setError(error);
if (error) return;
router.replace("/");
}}
className="m-2 my-6 w-60 self-center"
/>
{info?.allowRegister !== false && (
<P>
<Trans i18nKey="login.or-register">
Dont have an account?
<A href={`/register?apiUrl=${apiUrl}`}>Register</A>.
</Trans>
</P>
</OidcLogin>
)}
</FormPage>
);
};

View File

@ -1,32 +1,12 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Image, Platform, View } from "react-native";
import { z } from "zod/v4";
import { Button, HR, Link, P, Skeleton } from "~/primitives";
import { Image, View } from "react-native";
import { AuthInfo } from "~/models/auth-info";
import { Button, HRP, Link, Skeleton } from "~/primitives";
import { Fetch, type QueryIdentifier } from "~/query";
export const OidcLogin = ({
apiUrl,
children,
error,
}: {
apiUrl: string;
children: ReactNode;
error?: string;
}) => {
export const OidcLogin = ({ apiUrl }: { apiUrl: string }) => {
const { t } = useTranslation();
const or = (
<>
<View className="my-2 w-full flex-row items-center">
<HR className="grow" />
<P>{t("misc.or")}</P>
<HR className="grow" />
</View>
{children}
</>
);
return (
<Fetch
query={OidcLogin.query(apiUrl)}
@ -53,11 +33,7 @@ export const OidcLogin = ({
/>
))}
</View>
{info.allowRegister
? or
: error && (
<P className="text-red-500 dark:text-red-500">{error}</P>
)}
<HRP text={t("misc.or")} />
</>
)}
Loader={() => (
@ -69,7 +45,7 @@ export const OidcLogin = ({
</Button>
))}
</View>
{or}
<HRP text={t("misc.or")} />
</>
)}
/>
@ -81,33 +57,3 @@ OidcLogin.query = (apiUrl?: string): QueryIdentifier<AuthInfo> => ({
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<typeof AuthInfo>;

View File

@ -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 <ServerUrlPage />;
return (
<FormPage apiUrl={apiUrl!}>
<H1 className="pb-4">{t("login.register")}</H1>
<OidcLogin apiUrl={apiUrl}>
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
onChangeText={(value) => setUsername(value)}
/>
<P className="pt-2 pl-2">{t("login.email")}</P>
<Input autoComplete="email" onChangeText={(value) => setEmail(value)} />
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="new-password"
onChangeText={(value) => setPassword(value)}
/>
<P className="pt-2 pl-2">{t("login.confirm")}</P>
<PasswordInput
autoComplete="new-password"
onChangeText={(value) => setConfirm(value)}
/>
{password !== confirm && (
<P className="text-red-500 dark:text-red-500">
{t("login.password-no-match")}
</P>
)}
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
const { error } = await login("register", {
email,
username,
password,
apiUrl,
});
setError(error);
if (error) return;
router.replace("/");
}}
className="m-2 my-6 w-60 self-center"
/>
if (info?.allowRegister === false) {
return (
<FormPage apiUrl={apiUrl!}>
<OidcLogin apiUrl={apiUrl} />
<H1 className="pb-4">{t("login.register")}</H1>
<P className="mb-6">
{t(
Object.values(info.oidc).length > 0
? "login.register-disabled-oidc"
: "login.register-disabled",
)}
</P>
<P>
<Trans i18nKey="login.or-login">
Have an account already?
<A href={`/login?apiUrl=${apiUrl}`}>Log in</A>.
</Trans>
</P>
</OidcLogin>
</FormPage>
);
}
return (
<FormPage apiUrl={apiUrl!}>
<H1 className="pb-4">{t("login.register")}</H1>
<OidcLogin apiUrl={apiUrl} />
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
onChangeText={(value) => setUsername(value)}
/>
<P className="pt-2 pl-2">{t("login.email")}</P>
<Input autoComplete="email" onChangeText={(value) => setEmail(value)} />
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="new-password"
onChangeText={(value) => setPassword(value)}
/>
<P className="pt-2 pl-2">{t("login.confirm")}</P>
<PasswordInput
autoComplete="new-password"
onChangeText={(value) => setConfirm(value)}
/>
{password !== confirm && (
<P className="text-red-500 dark:text-red-500">
{t("login.password-no-match")}
</P>
)}
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
const { error } = await login("register", {
email,
username,
password,
apiUrl,
});
setError(error);
if (error) return;
router.replace("/");
}}
className="m-2 my-6 w-60 self-center"
/>
<P>
<Trans i18nKey="login.or-login">
Have an account already?
<A href={`/login?apiUrl=${apiUrl}`}>Log in</A>.
</Trans>
</P>
</FormPage>
);
};

View File

@ -35,6 +35,7 @@ import {
A,
Avatar,
HR,
HRP,
IconButton,
Link,
Menu,
@ -59,12 +60,6 @@ export const NavbarLeft = () => {
>
{t("navbar.browse")}
</A>
<A
href="/unmatched"
className="mx-2 font-headers text-lg text-slate-200 uppercase dark:text-slate-200"
>
{t("admin.unmatched.label")}
</A>
</View>
);
};
@ -258,6 +253,17 @@ export const NavbarProfile = () => {
/>
</>
)}
{account?.isAdmin && (
<>
<HRP text={t("navbar.admin")} />
<Menu.Item
label={t("admin.unmatched.label")}
icon={Search}
href="/unmatched"
/>
<Menu.Item label="Users" icon={Admin} href="/admin/users" />
</>
)}
</Menu>
);
};

View File

@ -1,10 +1,10 @@
import Username from "@material-symbols/svg-400/outlined/badge.svg";
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
// import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
// import * as ImagePicker from "expo-image-picker";
import * as ImagePicker from "expo-image-picker";
import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
@ -12,6 +12,7 @@ import { useUniwind } from "uniwind";
import type { KyooError, User } from "~/models";
import {
Alert,
Avatar,
Button,
type Icon,
Input,
@ -25,16 +26,6 @@ import { deleteAccount, logout } from "../login/logic";
import { PasswordInput } from "../login/password-input";
import { Preference, SettingsContainer } from "./base";
// function dataURItoBlob(dataURI: string) {
// const byteString = atob(dataURI.split(",")[1]);
// const ab = new ArrayBuffer(byteString.length);
// const ia = new Uint8Array(ab);
// for (let i = 0; i < byteString.length; i++) {
// ia[i] = byteString.charCodeAt(i);
// }
// return new Blob([ab], { type: "image/jpeg" });
// }
export const AccountSettings = () => {
const account = useAccount()!;
const { theme } = useUniwind();
@ -62,6 +53,15 @@ export const AccountSettings = () => {
invalidate: ["auth", "users", "me"],
});
const { mutateAsync: editLogo } = useMutation({
path: ["auth", "users", "me", "logo"],
compute: (formData: FormData | null) => ({
method: formData ? "POST" : "DELETE",
formData: formData ?? undefined,
}),
invalidate: null,
});
return (
<SettingsContainer
title={t("settings.account.label")}
@ -120,42 +120,43 @@ export const AccountSettings = () => {
}
/>
</Preference>
{/* <Preference */}
{/* icon={AccountCircle} */}
{/* customIcon={<Avatar src={account.logo} />} */}
{/* label={t("settings.account.avatar.label")} */}
{/* description={t("settings.account.avatar.description")} */}
{/* > */}
{/* <Button */}
{/* text={t("misc.edit")} */}
{/* onPress={async () => { */}
{/* const img = await ImagePicker.launchImageLibraryAsync({ */}
{/* mediaTypes: ImagePicker.MediaTypeOptions.Images, */}
{/* aspect: [1, 1], */}
{/* quality: 1, */}
{/* base64: true, */}
{/* }); */}
{/* if (img.canceled || img.assets.length !== 1) return; */}
{/* const data = dataURItoBlob(img.assets[0].uri); */}
{/* const formData = new FormData(); */}
{/* formData.append("picture", data); */}
{/* await queryFn({ */}
{/* method: "POST", */}
{/* path: ["auth", "me", "logo"], */}
{/* formData, */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* <Button */}
{/* text={t("misc.delete")} */}
{/* onPress={async () => { */}
{/* await queryFn({ */}
{/* method: "DELETE", */}
{/* path: ["auth", "me", "logo"], */}
{/* }); */}
{/* }} */}
{/* /> */}
{/* </Preference> */}
<Preference
icon={AccountCircle}
customIcon={
<Avatar src={account.logo} placeholder={account.username} />
}
label={t("settings.account.avatar.label")}
description={t("settings.account.avatar.description")}
>
<Button
text={t("misc.edit")}
onPress={async () => {
const img = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
allowsEditing: true,
aspect: [1, 1],
shape: "oval",
quality: 0,
base64: true,
});
if (img.canceled || img.assets.length !== 1) return;
const response = await fetch(img.assets[0].uri);
const formData = new FormData();
formData.append(
"logo",
await response.blob(),
img.assets[0].fileName ?? "logo.jpg",
);
await editLogo(formData);
}}
/>
<Button
text={t("misc.delete")}
onPress={async () => {
await editLogo(null);
}}
/>
</Preference>
<Preference
icon={Mail}
label={t("settings.account.email.label")}

View File

@ -55,12 +55,14 @@ export const SettingsContainer = ({
<H1 className="my-2 text-4xl">{title}</H1>
{extraTop}
<View className="rounded bg-card">
{Children.map(children, (x, i) => (
<Fragment key={i}>
{i !== 0 && <HR className="my-2" />}
{x}
</Fragment>
))}
{Children.toArray(children)
.filter((x) => x)
.map((x, i) => (
<Fragment key={i}>
{i !== 0 && <HR className="my-2" />}
{x}
</Fragment>
))}
</View>
{extra}
</Container>

View File

@ -4,6 +4,7 @@ import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
import { OidcSettings } from "./oidc";
import { PlaybackSettings } from "./playback";
import { SessionsSettings } from "./sessions";
export const SettingsPage = () => {
const account = useAccount();
@ -12,6 +13,7 @@ export const SettingsPage = () => {
<GeneralSettings />
{account && <PlaybackSettings />}
{account && <AccountSettings />}
{account && <SessionsSettings />}
{account && <OidcSettings />}
<About />
</ScrollView>

View File

@ -5,15 +5,15 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Image } from "react-native";
import { type KyooError, User } from "~/models";
import { AuthInfo } from "~/models/auth-info";
import { Button, IconButton, Link, P, Skeleton, tooltip } from "~/primitives";
import { type QueryIdentifier, useFetch, useMutation } from "~/query";
import { OidcLogin } from "../login/oidc";
import { Preference, SettingsContainer } from "./base";
export const OidcSettings = () => {
const { t } = useTranslation();
const [unlinkError, setUnlinkError] = useState<string | null>(null);
const { data } = useFetch(OidcLogin.query());
const { data } = useFetch(OidcSettings.authQuery());
const { data: user } = useFetch(OidcSettings.query());
const { mutateAsync: unlinkAccount } = useMutation({
method: "DELETE",
@ -104,3 +104,8 @@ OidcSettings.query = (): QueryIdentifier<User> => ({
path: ["auth", "users", "me"],
parser: User,
});
OidcSettings.authQuery = (): QueryIdentifier<AuthInfo> => ({
path: ["auth", "info"],
parser: AuthInfo,
});

View File

@ -0,0 +1,70 @@
import Devices from "@material-symbols/svg-400/outlined/devices.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { KyooError } from "~/models";
import { Button, P } from "~/primitives";
import { type QueryIdentifier, useFetch, useMutation } from "~/query";
import { Preference, SettingsContainer } from "./base";
export const SessionsSettings = () => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const { data: sessions } = useFetch(SessionsSettings.query());
const items = sessions ?? [];
const { mutateAsync: revokeSession, isPending } = useMutation({
method: "DELETE",
compute: (id: string) => ({
path: ["auth", "sessions", id],
}),
invalidate: ["auth", "users", "me", "sessions"],
});
return (
<SettingsContainer title={t("settings.sessions.label")}>
{error && <P className="mx-6 text-red-500">{error}</P>}
{items.map((session) => (
<Preference
key={session.id}
icon={Devices}
label={session.device}
description={
session.current
? t("settings.sessions.current")
: t("settings.sessions.description", {
createdDate: session.createdDate.toLocaleString(),
lastUsed: session.lastUsed.toLocaleString(),
})
}
>
<Button
text={t("settings.sessions.revoke")}
disabled={isPending}
onPress={async () => {
setError(null);
try {
await revokeSession(session.id);
} catch (e) {
setError((e as KyooError).message);
}
}}
/>
</Preference>
))}
</SettingsContainer>
);
};
SessionsSettings.query = (): QueryIdentifier<Session[]> => ({
path: ["auth", "users", "me", "sessions"],
parser: z.array(Session),
});
const Session = z.object({
id: z.string(),
createdDate: z.coerce.date(),
lastUsed: z.coerce.date(),
device: z.string(),
current: z.boolean(),
});
type Session = z.infer<typeof Session>;