diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 8d3581f..0c5e8fd 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
+ "github.com/vaultdrop/backend/internal/auth"
"github.com/vaultdrop/backend/internal/config"
"github.com/vaultdrop/backend/internal/db"
"github.com/vaultdrop/backend/internal/handler"
@@ -32,14 +33,18 @@ func main() {
fileSvc := service.NewFileService(queries, cfg)
ocrSvc := service.NewOCRService(cfg, fileSvc)
urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost)
+ authSvc, err := auth.NewAuthService(queries, cfg)
+ if err != nil {
+ log.Fatalf("Failed to create auth service: %v", err)
+ }
ocrSvc.Start()
defer ocrSvc.Stop()
- h := handler.New(fileSvc, ocrSvc, urlSvc)
+ h := handler.New(fileSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc))
r := gin.Default()
- handler.SetupRoutes(r, h)
+ handler.SetupRoutes(r, h, authSvc)
log.Printf("Server starting on port %s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
diff --git a/backend/go.mod b/backend/go.mod
index a8f9b3e..f7276f4 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -3,14 +3,18 @@ module github.com/vaultdrop/backend
go 1.26.4
require (
+ aidanwoods.dev/go-paseto v1.6.0
github.com/gin-gonic/gin v1.12.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
+ github.com/joho/godotenv v1.5.1
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
github.com/lib/pq v1.12.3
+ golang.org/x/crypto v0.52.0
)
require (
+ aidanwoods.dev/go-result v0.3.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -22,7 +26,6 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
- github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -36,7 +39,6 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
- golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index efb26ce..e28ed09 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,3 +1,7 @@
+aidanwoods.dev/go-paseto v1.6.0 h1:JA/PFk5lVsB/PakQGqnfmik/1tIHjE6F0UoPPoAO/nU=
+aidanwoods.dev/go-paseto v1.6.0/go.mod h1:LdqkL0Z2mLL0kBWzmHVR1cGFniX+zyOweQmbNKYrDxQ=
+aidanwoods.dev/go-result v0.3.1 h1:ee98hpohYUVYbI+pa6gUHTyoRerIudgjky/IPSowDXQ=
+aidanwoods.dev/go-result v0.3.1/go.mod h1:GKnFg8p/BKulVD3wsfULiPhpPmrTWyiTIbz8EWuUqSk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go
new file mode 100644
index 0000000..35c0478
--- /dev/null
+++ b/backend/internal/auth/handler.go
@@ -0,0 +1,135 @@
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/vaultdrop/backend/pkg/api"
+)
+
+type AuthHandler struct {
+ auth *AuthService
+}
+
+func NewAuthHandler(auth *AuthService) *AuthHandler {
+ return &AuthHandler{auth: auth}
+}
+
+type registerRequest struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+type loginRequest struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+type refreshRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
+
+type logoutRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
+
+type authResponse struct {
+ User UserResponse `json:"user"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+}
+
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+}
+
+func (h *AuthHandler) Register(c *gin.Context) {
+ var req registerRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "username and password are required")
+ return
+ }
+
+ tokens, user, err := h.auth.Register(c.Request.Context(), req.Username, req.Password)
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrUsernameTaken):
+ api.Error(c, http.StatusConflict, "USERNAME_TAKEN", "username already taken")
+ default:
+ api.Error(c, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
+ }
+ return
+ }
+
+ c.JSON(http.StatusCreated, authResponse{
+ User: *user,
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ })
+}
+
+func (h *AuthHandler) Login(c *gin.Context) {
+ var req loginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "username and password are required")
+ return
+ }
+
+ tokens, user, err := h.auth.Login(c.Request.Context(), req.Username, req.Password)
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrInvalidCredentials):
+ api.Error(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid username or password")
+ default:
+ api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong")
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, authResponse{
+ User: *user,
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ })
+}
+
+func (h *AuthHandler) Refresh(c *gin.Context) {
+ var req refreshRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "refresh_token is required")
+ return
+ }
+
+ tokens, err := h.auth.Refresh(c.Request.Context(), req.RefreshToken)
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrInvalidToken):
+ api.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "invalid or expired refresh token")
+ default:
+ api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong")
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, tokenResponse{
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ })
+}
+
+func (h *AuthHandler) Logout(c *gin.Context) {
+ var req logoutRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "refresh_token is required")
+ return
+ }
+
+ if err := h.auth.Logout(c.Request.Context(), req.RefreshToken); err != nil {
+ api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong")
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "logged out"})
+}
diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go
new file mode 100644
index 0000000..e19911f
--- /dev/null
+++ b/backend/internal/auth/middleware.go
@@ -0,0 +1,41 @@
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+const UserIDKey = "userID"
+
+func (s *AuthService) RequireAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ header := c.GetHeader("Authorization")
+ if header == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "error": gin.H{"code": "MISSING_TOKEN", "message": "authorization header required"},
+ })
+ return
+ }
+
+ parts := strings.SplitN(header, " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "error": gin.H{"code": "INVALID_TOKEN", "message": "invalid authorization format"},
+ })
+ return
+ }
+
+ claims, err := s.ValidateAccessToken(parts[1])
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "error": gin.H{"code": "INVALID_TOKEN", "message": "invalid or expired token"},
+ })
+ return
+ }
+
+ c.Set(UserIDKey, claims)
+ c.Next()
+ }
+}
diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go
new file mode 100644
index 0000000..7daced1
--- /dev/null
+++ b/backend/internal/auth/service.go
@@ -0,0 +1,250 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "aidanwoods.dev/go-paseto"
+ "github.com/vaultdrop/backend/internal/config"
+ "github.com/vaultdrop/backend/internal/db"
+ "golang.org/x/crypto/argon2"
+)
+
+var (
+ ErrInvalidCredentials = errors.New("invalid credentials")
+ ErrUsernameTaken = errors.New("username already taken")
+ ErrInvalidToken = errors.New("invalid or expired token")
+)
+
+const (
+ accessTokenTTL = 30 * time.Minute
+ refreshTokenTTL = 7 * 24 * time.Hour
+ saltLength = 16
+)
+
+type AuthService struct {
+ queries *db.Queries
+ key paseto.V4SymmetricKey
+ parser *paseto.Parser
+}
+
+type TokenPair struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+}
+
+type UserResponse struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+}
+
+func NewAuthService(queries *db.Queries, cfg *config.Config) (*AuthService, error) {
+ key, err := paseto.V4SymmetricKeyFromHex(cfg.PASETOKey)
+ if err != nil {
+ return nil, fmt.Errorf("invalid paseto key: %w", err)
+ }
+
+ parser := paseto.NewParser()
+ parser.AddRule(paseto.NotExpired())
+
+ return &AuthService{
+ queries: queries,
+ key: key,
+ parser: &parser,
+ }, nil
+}
+
+func (s *AuthService) Register(ctx context.Context, username, password string) (*TokenPair, *UserResponse, error) {
+ username = strings.TrimSpace(username)
+ if len(username) < 3 || len(username) > 30 {
+ return nil, nil, fmt.Errorf("username must be 3-30 characters")
+ }
+ if len(password) < 8 {
+ return nil, nil, fmt.Errorf("password must be at least 8 characters")
+ }
+
+ existing, err := s.queries.GetUserByUsername(ctx, username)
+ if err == nil && existing.ID != "" {
+ return nil, nil, ErrUsernameTaken
+ }
+
+ hash, err := hashPassword(password)
+ if err != nil {
+ return nil, nil, fmt.Errorf("hash password: %w", err)
+ }
+
+ user, err := s.queries.CreateUser(ctx, db.CreateUserParams{
+ Username: username,
+ PasswordHash: hash,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("create user: %w", err)
+ }
+
+ tokens, err := s.generateTokens(ctx, user.ID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate tokens: %w", err)
+ }
+
+ return tokens, &UserResponse{ID: user.ID, Username: user.Username}, nil
+}
+
+func (s *AuthService) Login(ctx context.Context, username, password string) (*TokenPair, *UserResponse, error) {
+ user, err := s.queries.GetUserByUsername(ctx, strings.TrimSpace(username))
+ if err != nil {
+ return nil, nil, ErrInvalidCredentials
+ }
+
+ if !verifyPassword(password, user.PasswordHash) {
+ return nil, nil, ErrInvalidCredentials
+ }
+
+ tokens, err := s.generateTokens(ctx, user.ID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate tokens: %w", err)
+ }
+
+ return tokens, &UserResponse{ID: user.ID, Username: user.Username}, nil
+}
+
+func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
+ userID, err := s.validateRefreshToken(refreshToken)
+ if err != nil {
+ return nil, ErrInvalidToken
+ }
+
+ tokenHash := hashToken(refreshToken)
+ stored, err := s.queries.GetRefreshToken(ctx, tokenHash)
+ if err != nil {
+ return nil, ErrInvalidToken
+ }
+
+ if stored.UserID != userID {
+ return nil, ErrInvalidToken
+ }
+
+ if err := s.queries.RevokeRefreshToken(ctx, tokenHash); err != nil {
+ return nil, fmt.Errorf("revoke refresh token: %w", err)
+ }
+
+ tokens, err := s.generateTokens(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("generate tokens: %w", err)
+ }
+
+ return tokens, nil
+}
+
+func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
+ tokenHash := hashToken(refreshToken)
+ return s.queries.RevokeRefreshToken(ctx, tokenHash)
+}
+
+func (s *AuthService) ValidateAccessToken(token string) (string, error) {
+ parsed, err := s.parser.ParseV4Local(s.key, token, nil)
+ if err != nil {
+ return "", ErrInvalidToken
+ }
+
+ userID, err := parsed.GetString("user_id")
+ if err != nil || userID == "" {
+ return "", ErrInvalidToken
+ }
+
+ return userID, nil
+}
+
+func (s *AuthService) generateTokens(ctx context.Context, userID string) (*TokenPair, error) {
+ accessToken, err := s.createAccessToken(userID)
+ if err != nil {
+ return nil, err
+ }
+
+ refreshToken, err := s.createRefreshToken(userID)
+ if err != nil {
+ return nil, err
+ }
+
+ refreshHash := hashToken(refreshToken)
+ _, err = s.queries.CreateRefreshToken(ctx, db.CreateRefreshTokenParams{
+ UserID: userID,
+ TokenHash: refreshHash,
+ ExpiresAt: time.Now().Add(refreshTokenTTL),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("store refresh token: %w", err)
+ }
+
+ return &TokenPair{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ }, nil
+}
+
+func (s *AuthService) createAccessToken(userID string) (string, error) {
+ token := paseto.NewToken()
+ token.Set("user_id", userID)
+ token.SetExpiration(time.Now().Add(accessTokenTTL))
+ return token.V4Encrypt(s.key, nil), nil
+}
+
+func (s *AuthService) createRefreshToken(userID string) (string, error) {
+ token := paseto.NewToken()
+ token.Set("user_id", userID)
+ token.SetExpiration(time.Now().Add(refreshTokenTTL))
+ return token.V4Encrypt(s.key, nil), nil
+}
+
+func (s *AuthService) validateRefreshToken(token string) (string, error) {
+ parsed, err := s.parser.ParseV4Local(s.key, token, nil)
+ if err != nil {
+ return "", ErrInvalidToken
+ }
+
+ userID, err := parsed.GetString("user_id")
+ if err != nil || userID == "" {
+ return "", ErrInvalidToken
+ }
+
+ return userID, nil
+}
+
+func hashPassword(password string) (string, error) {
+ salt := make([]byte, saltLength)
+ if _, err := rand.Read(salt); err != nil {
+ return "", err
+ }
+ hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
+ return fmt.Sprintf("$argon2id$v=19$m=65536,t=1,p=4$%s$%s",
+ base64.RawStdEncoding.EncodeToString(salt),
+ base64.RawStdEncoding.EncodeToString(hash),
+ ), nil
+}
+
+func verifyPassword(password, encodedHash string) bool {
+ parts := strings.Split(encodedHash, "$")
+ if len(parts) != 6 {
+ return false
+ }
+ salt, err := base64.RawStdEncoding.DecodeString(parts[4])
+ if err != nil {
+ return false
+ }
+ expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
+ if err != nil {
+ return false
+ }
+ hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
+ return sha256.Sum256(hash) == sha256.Sum256(expectedHash)
+}
+
+func hashToken(token string) string {
+ h := sha256.Sum256([]byte(token))
+ return base64.RawURLEncoding.EncodeToString(h[:])
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index c5711b5..852eaf7 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -9,6 +9,7 @@ type Config struct {
UploadDir string
HMACSecret string
ServerHost string
+ PASETOKey string
}
func Load() *Config {
@@ -19,6 +20,7 @@ func Load() *Config {
UploadDir: envOr("UPLOAD_DIR", "./uploads"),
HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"),
ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"),
+ PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"),
}
}
diff --git a/backend/internal/db/auth.sql.go b/backend/internal/db/auth.sql.go
new file mode 100644
index 0000000..95b3853
--- /dev/null
+++ b/backend/internal/db/auth.sql.go
@@ -0,0 +1,132 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: auth.sql
+
+package db
+
+import (
+ "context"
+ "time"
+)
+
+const createRefreshToken = `-- name: CreateRefreshToken :one
+INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
+VALUES ($1, $2, $3)
+RETURNING id, user_id, token_hash, expires_at, revoked, created_at
+`
+
+type CreateRefreshTokenParams struct {
+ UserID string `json:"user_id"`
+ TokenHash string `json:"token_hash"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
+ row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.TokenHash, arg.ExpiresAt)
+ var i RefreshToken
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.TokenHash,
+ &i.ExpiresAt,
+ &i.Revoked,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const createUser = `-- name: CreateUser :one
+INSERT INTO users (username, password_hash)
+VALUES ($1, $2)
+RETURNING id, username, password_hash, created_at, updated_at
+`
+
+type CreateUserParams struct {
+ Username string `json:"username"`
+ PasswordHash string `json:"password_hash"`
+}
+
+func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
+ row := q.db.QueryRowContext(ctx, createUser, arg.Username, arg.PasswordHash)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.PasswordHash,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getRefreshToken = `-- name: GetRefreshToken :one
+SELECT id, user_id, token_hash, expires_at, revoked, created_at FROM refresh_tokens
+WHERE token_hash = $1 AND revoked = FALSE AND expires_at > NOW()
+`
+
+func (q *Queries) GetRefreshToken(ctx context.Context, tokenHash string) (RefreshToken, error) {
+ row := q.db.QueryRowContext(ctx, getRefreshToken, tokenHash)
+ var i RefreshToken
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.TokenHash,
+ &i.ExpiresAt,
+ &i.Revoked,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getUserByID = `-- name: GetUserByID :one
+SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1
+`
+
+func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
+ row := q.db.QueryRowContext(ctx, getUserByID, id)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.PasswordHash,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getUserByUsername = `-- name: GetUserByUsername :one
+SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1
+`
+
+func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
+ row := q.db.QueryRowContext(ctx, getUserByUsername, username)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.PasswordHash,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const revokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
+UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1
+`
+
+func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID string) error {
+ _, err := q.db.ExecContext(ctx, revokeAllUserRefreshTokens, userID)
+ return err
+}
+
+const revokeRefreshToken = `-- name: RevokeRefreshToken :exec
+UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1
+`
+
+func (q *Queries) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
+ _, err := q.db.ExecContext(ctx, revokeRefreshToken, tokenHash)
+ return err
+}
diff --git a/backend/internal/db/migrations/009_users_default.down.sql b/backend/internal/db/migrations/009_users_default.down.sql
new file mode 100644
index 0000000..555e0ea
--- /dev/null
+++ b/backend/internal/db/migrations/009_users_default.down.sql
@@ -0,0 +1,5 @@
+CREATE TABLE users (
+ id TEXT NOT NULL DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/backend/internal/db/migrations/009_users_default.up.sql b/backend/internal/db/migrations/009_users_default.up.sql
new file mode 100644
index 0000000..365a210
--- /dev/null
+++ b/backend/internal/db/migrations/009_users_default.up.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS users;
\ No newline at end of file
diff --git a/backend/internal/db/migrations/010_auth.down.sql b/backend/internal/db/migrations/010_auth.down.sql
new file mode 100644
index 0000000..7150d35
--- /dev/null
+++ b/backend/internal/db/migrations/010_auth.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS refresh_tokens;
+DROP TABLE IF EXISTS users;
diff --git a/backend/internal/db/migrations/010_auth.up.sql b/backend/internal/db/migrations/010_auth.up.sql
new file mode 100644
index 0000000..9b3ea73
--- /dev/null
+++ b/backend/internal/db/migrations/010_auth.up.sql
@@ -0,0 +1,19 @@
+CREATE TABLE users (
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE refresh_tokens (
+ id TEXT NOT NULL DEFAULT gen_random_uuid(),
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ revoked BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
+CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
diff --git a/backend/internal/db/models.go b/backend/internal/db/models.go
index a54cc01..26b9467 100644
--- a/backend/internal/db/models.go
+++ b/backend/internal/db/models.go
@@ -29,6 +29,15 @@ type FileTag struct {
FileID sql.NullString `json:"file_id"`
}
+type RefreshToken struct {
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+ TokenHash string `json:"token_hash"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Revoked bool `json:"revoked"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
type Tag struct {
ID string `json:"id"`
ParentTagID sql.NullString `json:"parent_tag_id"`
@@ -37,3 +46,11 @@ type Tag struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+
+type User struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ PasswordHash string `json:"password_hash"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/backend/internal/db/queries/auth.sql b/backend/internal/db/queries/auth.sql
new file mode 100644
index 0000000..cc88066
--- /dev/null
+++ b/backend/internal/db/queries/auth.sql
@@ -0,0 +1,25 @@
+-- name: CreateUser :one
+INSERT INTO users (username, password_hash)
+VALUES ($1, $2)
+RETURNING *;
+
+-- name: GetUserByUsername :one
+SELECT * FROM users WHERE username = $1;
+
+-- name: GetUserByID :one
+SELECT * FROM users WHERE id = $1;
+
+-- name: CreateRefreshToken :one
+INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
+VALUES ($1, $2, $3)
+RETURNING *;
+
+-- name: GetRefreshToken :one
+SELECT * FROM refresh_tokens
+WHERE token_hash = $1 AND revoked = FALSE AND expires_at > NOW();
+
+-- name: RevokeRefreshToken :exec
+UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1;
+
+-- name: RevokeAllUserRefreshTokens :exec
+UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1;
diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go
index 6bb1f8f..1bdd3dc 100644
--- a/backend/internal/handler/handler.go
+++ b/backend/internal/handler/handler.go
@@ -1,6 +1,7 @@
package handler
import (
+ "github.com/vaultdrop/backend/internal/auth"
"github.com/vaultdrop/backend/internal/service"
)
@@ -8,12 +9,14 @@ type Handler struct {
File *FileHandler
OCR *OCRHandler
Health *HealthHandler
+ Auth *auth.AuthHandler
}
-func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService) *Handler {
+func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService, authHandler *auth.AuthHandler) *Handler {
return &Handler{
File: &FileHandler{files: fileSvc, urls: urlSvc, ocr: ocrSvc},
OCR: &OCRHandler{ocr: ocrSvc, files: fileSvc},
Health: &HealthHandler{ocr: ocrSvc},
+ Auth: authHandler,
}
}
diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go
index 31d5e32..1a641bf 100644
--- a/backend/internal/handler/router.go
+++ b/backend/internal/handler/router.go
@@ -1,25 +1,37 @@
package handler
-import "github.com/gin-gonic/gin"
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/vaultdrop/backend/internal/auth"
+)
-func SetupRoutes(r *gin.Engine, h *Handler) {
+func SetupRoutes(r *gin.Engine, h *Handler, authMiddleware *auth.AuthService) {
api := r.Group("/api/v1")
+ // Public
api.GET("/health", h.Health.Check)
+ api.POST("/auth/register", h.Auth.Register)
+ api.POST("/auth/login", h.Auth.Login)
+ api.POST("/auth/refresh", h.Auth.Refresh)
+ api.POST("/auth/logout", h.Auth.Logout)
- api.GET("/files", h.File.List)
- api.POST("/files/upload", h.File.Upload)
- api.POST("/files/move", h.File.MoveFiles)
- api.POST("/files/folders", h.File.CreateFolder)
- api.GET("/files/folders", h.File.ListFolders)
- api.GET("/files/folders/:id/files", h.File.ListFilesByParent)
- api.GET("/files/download/:id", h.File.Download)
- api.DELETE("/files/:id", h.File.Delete)
- api.GET("/files/:id", h.File.Get)
+ // Protected
+ protected := api.Group("")
+ protected.Use(authMiddleware.RequireAuth())
- api.POST("/files/:id/tags", h.File.AddTags)
- api.GET("/files/:id/tags", h.File.GetTags)
+ protected.GET("/files", h.File.List)
+ protected.POST("/files/upload", h.File.Upload)
+ protected.POST("/files/move", h.File.MoveFiles)
+ protected.POST("/files/folders", h.File.CreateFolder)
+ protected.GET("/files/folders", h.File.ListFolders)
+ protected.GET("/files/folders/:id/files", h.File.ListFilesByParent)
+ protected.GET("/files/download/:id", h.File.Download)
+ protected.DELETE("/files/:id", h.File.Delete)
+ protected.GET("/files/:id", h.File.Get)
- api.POST("/ocr/jobs", h.OCR.CreateJob)
- api.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
+ protected.POST("/files/:id/tags", h.File.AddTags)
+ protected.GET("/files/:id/tags", h.File.GetTags)
+
+ protected.POST("/ocr/jobs", h.OCR.CreateJob)
+ protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus)
}
diff --git a/mobile/App.tsx b/mobile/App.tsx
index 281df21..10ec5bd 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -1,8 +1,12 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
+import { ActivityIndicator, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { AuthProvider, useAuth } from './contexts/AuthContext';
+import { LoginScreen } from './app/login';
+import { RegisterScreen } from './app/register';
import { HomeScreen } from './app/index';
import { UploadScreen } from './app/upload';
import { ScanScreen } from './app/scan';
@@ -16,32 +20,61 @@ import { FolderScreen } from './app/folder';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
+function AppNavigator() {
+ const { user, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {user ? (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
+
export default function App() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/mobile/api/client.ts b/mobile/api/client.ts
index 92402be..13df5a2 100644
--- a/mobile/api/client.ts
+++ b/mobile/api/client.ts
@@ -1,26 +1,47 @@
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, HttpError } from '../types';
+import { tokenStorage } from './secureStorage';
class ApiClient {
private baseUrl: string;
+ private accessToken: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
+ setAccessToken(token: string | null) {
+ this.accessToken = token;
+ }
+
private async request(
endpoint: string,
- options: RequestInit = {}
+ options: RequestInit = {},
+ isRetry = false
): Promise {
const url = `${this.baseUrl}${endpoint}`;
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(options.headers as Record),
+ };
+
+ if (this.accessToken) {
+ headers['Authorization'] = `Bearer ${this.accessToken}`;
+ }
+
const response = await fetch(url, {
...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers,
- },
+ headers,
});
+ if (response.status === 401 && !isRetry) {
+ const refreshed = await this.tryRefreshToken();
+ if (refreshed) {
+ return this.request(endpoint, options, true);
+ }
+ throw new HttpError(401, 'Session expirée', 'UNAUTHORIZED');
+ }
+
if (!response.ok) {
let message = 'Request failed';
let code: string | undefined;
@@ -35,6 +56,39 @@ class ApiClient {
return response.json();
}
+ private async tryRefreshToken(): Promise {
+ try {
+ const refreshToken = await tokenStorage.getRefreshToken();
+ if (!refreshToken) return false;
+
+ const url = `${this.baseUrl}${ENDPOINTS.AUTH_REFRESH}`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: refreshToken }),
+ });
+
+ if (!response.ok) {
+ await this.clearAuth();
+ return false;
+ }
+
+ const data = await response.json();
+ this.accessToken = data.access_token;
+ await tokenStorage.setRefreshToken(data.refresh_token);
+ return true;
+ } catch {
+ await this.clearAuth();
+ return false;
+ }
+ }
+
+ private async clearAuth() {
+ this.accessToken = null;
+ await tokenStorage.deleteRefreshToken();
+ await tokenStorage.deleteUser();
+ }
+
async get(endpoint: string): Promise {
return this.request(endpoint);
}
diff --git a/mobile/api/secureStorage.ts b/mobile/api/secureStorage.ts
new file mode 100644
index 0000000..4f84240
--- /dev/null
+++ b/mobile/api/secureStorage.ts
@@ -0,0 +1,30 @@
+import * as SecureStore from 'expo-secure-store';
+
+const REFRESH_KEY = 'vaultdrop_refresh_token';
+const USER_KEY = 'vaultdrop_user';
+
+export const tokenStorage = {
+ async getRefreshToken(): Promise {
+ return SecureStore.getItemAsync(REFRESH_KEY);
+ },
+
+ async setRefreshToken(token: string): Promise {
+ await SecureStore.setItemAsync(REFRESH_KEY, token);
+ },
+
+ async deleteRefreshToken(): Promise {
+ await SecureStore.deleteItemAsync(REFRESH_KEY);
+ },
+
+ async getUser(): Promise {
+ return SecureStore.getItemAsync(USER_KEY);
+ },
+
+ async setUser(user: string): Promise {
+ await SecureStore.setItemAsync(USER_KEY, user);
+ },
+
+ async deleteUser(): Promise {
+ await SecureStore.deleteItemAsync(USER_KEY);
+ },
+};
diff --git a/mobile/app.json b/mobile/app.json
index 40b1782..e523c26 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -31,6 +31,9 @@
},
"web": {
"favicon": "./assets/favicon.png"
- }
+ },
+ "plugins": [
+ "expo-secure-store"
+ ]
}
}
diff --git a/mobile/app/file-edit.tsx b/mobile/app/file-edit.tsx
index 6ef59f3..ce7966e 100644
--- a/mobile/app/file-edit.tsx
+++ b/mobile/app/file-edit.tsx
@@ -30,9 +30,11 @@ type FileEditRouteParams = {
interface FileEditItemProps {
fileId: string;
+ selected: boolean;
+ onPress: () => void;
}
-function FileEditItem({ fileId }: FileEditItemProps) {
+function FileEditItem({ fileId, selected, onPress }: FileEditItemProps) {
const { data: fileData, isLoading: fileLoading } = useFile(fileId);
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
const file = fileData as any;
@@ -41,14 +43,14 @@ function FileEditItem({ fileId }: FileEditItemProps) {
if (isLoading) {
return (
-
+
-
+
);
}
return (
-
+
{uri && file?.mimeType?.startsWith('image/') ? (
) : (
@@ -58,7 +60,14 @@ function FileEditItem({ fileId }: FileEditItemProps) {
size={ITEM_SIZE}
/>
)}
-
+ {selected && (
+
+
+
+
+
+ )}
+
);
}
@@ -74,6 +83,26 @@ export function FileEditScreen() {
const [tagInput, setTagInput] = useState('');
const [pendingTags, setPendingTags] = useState([]);
const [uploading, setUploading] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const hasSelection = selectedIds.size > 0;
+ const targetIds = hasSelection ? Array.from(selectedIds) : fileIds;
+
+ const toggleSelection = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const toggleSelectAll = useCallback(() => {
+ setSelectedIds((prev) => {
+ if (prev.size === fileIds.length) return new Set();
+ return new Set(fileIds);
+ });
+ }, [fileIds]);
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
@@ -88,21 +117,21 @@ export function FileEditScreen() {
const handleApplyTags = useCallback(async () => {
if (pendingTags.length === 0) return;
- for (const fileId of fileIds) {
+ for (const fileId of targetIds) {
await addTags.mutateAsync({ fileId, tags: pendingTags });
}
Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`);
setPendingTags([]);
- }, [pendingTags, fileIds, addTags]);
+ }, [pendingTags, targetIds, addTags]);
const handleGeneratePdf = useCallback(async () => {
- if (fileIds.length === 0) return;
+ if (targetIds.length === 0) return;
setUploading(true);
try {
const imageUris: { uri: string }[] = [];
- for (const fileId of fileIds) {
+ for (const fileId of targetIds) {
const response = await fetch(`${process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'}/files/${fileId}`);
const data = await response.json();
const url = data?.data?.url;
@@ -152,17 +181,26 @@ export function FileEditScreen() {
} finally {
setUploading(false);
}
- }, [fileIds, generatePdf, upload, navigation]);
+ }, [targetIds, generatePdf, upload, navigation]);
const isLoading = generating || uploading;
return (
- Édition
-
- {fileIds.length} fichier{fileIds.length > 1 ? 's' : ''} sélectionné{fileIds.length > 1 ? 's' : ''}
-
+
+
+ Édition
+
+ {hasSelection
+ ? `${selectedIds.size} sélectionné${selectedIds.size > 1 ? 's' : ''} / ${fileIds.length}`
+ : `${fileIds.length} fichier${fileIds.length > 1 ? 's' : ''}`}
+
+
+
+ {hasSelection && selectedIds.size === fileIds.length ? 'Tout' : 'Tout'}
+
+
item}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
- renderItem={({ item }) => }
+ renderItem={({ item }) => (
+ toggleSelection(item)}
+ />
+ )}
/>
@@ -243,6 +287,11 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
+ headerRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
title: {
fontSize: 20,
fontWeight: '700',
@@ -252,6 +301,17 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#666',
},
+ selectAllBtn: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 16,
+ backgroundColor: '#E3F2FD',
+ },
+ selectAllText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#1976D2',
+ },
grid: {
padding: 16,
},
@@ -270,6 +330,24 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
+ selectedOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ justifyContent: 'flex-start',
+ alignItems: 'flex-end',
+ padding: 4,
+ },
+ checkCircle: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: '#1976D2',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
thumb: {
width: '100%',
height: '100%',
diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx
new file mode 100644
index 0000000..d53b99a
--- /dev/null
+++ b/mobile/app/login.tsx
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ StyleSheet,
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ActivityIndicator,
+} from 'react-native';
+import { useAuth } from '../contexts/AuthContext';
+
+export function LoginScreen({ navigation }: any) {
+ const { login } = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleLogin() {
+ if (!username.trim() || !password) {
+ Alert.alert('Erreur', 'Veuillez remplir tous les champs');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await login(username.trim(), password);
+ } catch (error: any) {
+ Alert.alert('Erreur', error.message || 'Connexion échouée');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ Dot.
+ Connectez-vous à votre compte
+
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Se connecter
+ )}
+
+
+ navigation.navigate('Register')}
+ disabled={loading}
+ >
+ Pas de compte ? S'inscrire
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ inner: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: 32,
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#666',
+ textAlign: 'center',
+ marginBottom: 32,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 16,
+ fontSize: 16,
+ marginBottom: 16,
+ },
+ button: {
+ backgroundColor: '#000',
+ borderRadius: 8,
+ padding: 16,
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ linkButton: {
+ alignItems: 'center',
+ },
+ linkText: {
+ color: '#000',
+ fontSize: 14,
+ },
+});
diff --git a/mobile/app/register.tsx b/mobile/app/register.tsx
new file mode 100644
index 0000000..ada2208
--- /dev/null
+++ b/mobile/app/register.tsx
@@ -0,0 +1,161 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ StyleSheet,
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ActivityIndicator,
+} from 'react-native';
+import { useAuth } from '../contexts/AuthContext';
+
+export function RegisterScreen({ navigation }: any) {
+ const { register } = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleRegister() {
+ if (!username.trim() || !password || !confirmPassword) {
+ Alert.alert('Erreur', 'Veuillez remplir tous les champs');
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
+ return;
+ }
+
+ if (password.length < 8) {
+ Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await register(username.trim(), password);
+ } catch (error: any) {
+ Alert.alert('Erreur', error.message || "Inscription échouée");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ Dot.
+ Créez votre compte
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ S'inscrire
+ )}
+
+
+ navigation.navigate('Login')}
+ disabled={loading}
+ >
+ Déjà un compte ? Se connecter
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ inner: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: 32,
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#666',
+ textAlign: 'center',
+ marginBottom: 32,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 16,
+ fontSize: 16,
+ marginBottom: 16,
+ },
+ button: {
+ backgroundColor: '#000',
+ borderRadius: 8,
+ padding: 16,
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ linkButton: {
+ alignItems: 'center',
+ },
+ linkText: {
+ color: '#000',
+ fontSize: 14,
+ },
+});
diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts
index 4d55977..589dc51 100644
--- a/mobile/constants/api.ts
+++ b/mobile/constants/api.ts
@@ -9,4 +9,8 @@ export const ENDPOINTS = {
FOLDERS: '/files/folders',
OCR_JOBS: '/ocr/jobs',
HEALTH: '/health',
+ AUTH_LOGIN: '/auth/login',
+ AUTH_REGISTER: '/auth/register',
+ AUTH_REFRESH: '/auth/refresh',
+ AUTH_LOGOUT: '/auth/logout',
} as const;
diff --git a/mobile/contexts/AuthContext.tsx b/mobile/contexts/AuthContext.tsx
new file mode 100644
index 0000000..b1fe596
--- /dev/null
+++ b/mobile/contexts/AuthContext.tsx
@@ -0,0 +1,90 @@
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { apiClient } from '../api/client';
+import { tokenStorage } from '../api/secureStorage';
+import { ENDPOINTS } from '../constants/api';
+import { AuthResponse, User } from '../types';
+
+interface AuthContextType {
+ user: User | null;
+ isLoading: boolean;
+ login: (username: string, password: string) => Promise;
+ register: (username: string, password: string) => Promise;
+ logout: () => Promise;
+}
+
+const AuthContext = createContext(undefined);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ loadUser();
+ }, []);
+
+ async function loadUser() {
+ try {
+ const stored = await tokenStorage.getUser();
+ if (stored) {
+ const userData = JSON.parse(stored) as User;
+ setUser(userData);
+ }
+ } catch {
+ await tokenStorage.deleteUser();
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ async function login(username: string, password: string) {
+ const response = await apiClient.post(ENDPOINTS.AUTH_LOGIN, {
+ username,
+ password,
+ });
+
+ apiClient.setAccessToken(response.access_token);
+ await tokenStorage.setRefreshToken(response.refresh_token);
+ await tokenStorage.setUser(JSON.stringify(response.user));
+ setUser(response.user);
+ }
+
+ async function register(username: string, password: string) {
+ const response = await apiClient.post(ENDPOINTS.AUTH_REGISTER, {
+ username,
+ password,
+ });
+
+ apiClient.setAccessToken(response.access_token);
+ await tokenStorage.setRefreshToken(response.refresh_token);
+ await tokenStorage.setUser(JSON.stringify(response.user));
+ setUser(response.user);
+ }
+
+ async function logout() {
+ try {
+ const refreshToken = await tokenStorage.getRefreshToken();
+ if (refreshToken) {
+ await apiClient.post(ENDPOINTS.AUTH_LOGOUT, { refresh_token: refreshToken });
+ }
+ } catch {}
+
+ apiClient.setAccessToken(null);
+ await tokenStorage.deleteRefreshToken();
+ await tokenStorage.deleteUser();
+ setUser(null);
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+}
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 2d67655..45cdffd 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -17,6 +17,7 @@
"expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
+ "expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react": "19.2.3",
"react-native": "0.86.0",
@@ -2924,6 +2925,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-secure-store": {
+ "version": "57.0.0",
+ "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.0.tgz",
+ "integrity": "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 71fbbcf..41c37d0 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -12,6 +12,7 @@
"expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
+ "expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react": "19.2.3",
"react-native": "0.86.0",
diff --git a/mobile/types/index.ts b/mobile/types/index.ts
index d555765..78c9d77 100644
--- a/mobile/types/index.ts
+++ b/mobile/types/index.ts
@@ -83,3 +83,24 @@ export type Batch = {
photos: CapturedPhoto[];
tags: string[];
};
+
+export interface User {
+ id: string;
+ username: string;
+}
+
+export interface AuthTokens {
+ access_token: string;
+ refresh_token: string;
+}
+
+export interface AuthResponse {
+ user: User;
+ access_token: string;
+ refresh_token: string;
+}
+
+export interface RefreshResponse {
+ access_token: string;
+ refresh_token: string;
+}