mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-29 04:42:11 -04:00
Auth missing features (#1394)
This commit is contained in:
commit
01548fbb2d
11
.env.example
11
.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=<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}'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
337
auth/logo.go
Normal 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)
|
||||
}
|
||||
63
auth/main.go
63
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)
|
||||
|
||||
58
auth/oidc.go
58
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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 -}}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
26
chart/templates/auth/pvc.yaml
Normal file
26
chart/templates/auth/pvc.yaml
Normal 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 }}
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -107,6 +107,13 @@ export const expo: ExpoConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { AddPage } from "~/ui/admin/add";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default AddPage;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { MatchPage } from "~/ui/admin/match";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default MatchPage;
|
||||
|
||||
5
front/src/app/(app)/admin/users.tsx
Normal file
5
front/src/app/(app)/admin/users.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { AdminUsersPage } from "~/ui/admin/users";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default AdminUsersPage;
|
||||
32
front/src/models/auth-info.ts
Normal file
32
front/src/models/auth-info.ts
Normal 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>;
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./users";
|
||||
export * from "./videos-modal";
|
||||
|
||||
244
front/src/ui/admin/users.tsx
Normal file
244
front/src/ui/admin/users.tsx
Normal 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"],
|
||||
});
|
||||
@ -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">
|
||||
Don’t have an account?
|
||||
<A href={`/register?apiUrl=${apiUrl}`}>Register</A>.
|
||||
</Trans>
|
||||
</P>
|
||||
</OidcLogin>
|
||||
)}
|
||||
</FormPage>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
70
front/src/ui/settings/sessions.tsx
Normal file
70
front/src/ui/settings/sessions.tsx
Normal 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>;
|
||||
Loading…
x
Reference in New Issue
Block a user