Add admin-create registration + live email-verified flag
Registration now goes through POST /api/auth/register, which admin-creates the Kratos identity (unverified email, NO auto-sent code). Kratos self-service registration never returns the verification flow id, so the client could never submit the user's code to the right flow; admin creation lets the client own a single verification flow instead. Also surface the live Kratos verified flag and fix Apple audience + team IDs. - kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials - AuthService.Register + AuthHandler.Register + public POST /api/auth/register/ - CurrentUser overrides stale user_profile.verified with the live Kratos flag; UserRepository.MarkVerified mirrors it back - configmap: additional_id_token_audiences allows the .dev bundle id_token - fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue <noreply@honeyDue.treytartt.com>
|
|||||||
# Release builds: com.myhoneydue.honeyDue
|
# Release builds: com.myhoneydue.honeyDue
|
||||||
# Debug builds: com.myhoneydue.honeyDue.dev
|
# Debug builds: com.myhoneydue.honeyDue.dev
|
||||||
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
|
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
|
||||||
APPLE_TEAM_ID=V3PF3M6B6U
|
APPLE_TEAM_ID=X86BR9WTLD
|
||||||
|
|
||||||
# APNs Settings (iOS Push Notifications)
|
# APNs Settings (iOS Push Notifications)
|
||||||
# Direct APNs integration - no external push server needed
|
# Direct APNs integration - no external push server needed
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)"
|
|||||||
|
|
||||||
EMAIL_USER="treytartt@fastmail.com"
|
EMAIL_USER="treytartt@fastmail.com"
|
||||||
APNS_KEY_ID="9R5Q7ZX874"
|
APNS_KEY_ID="9R5Q7ZX874"
|
||||||
APNS_TEAM_ID="V3PF3M6B6U"
|
APNS_TEAM_ID="X86BR9WTLD"
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "Pre-filled from existing dev server:"
|
log "Pre-filled from existing dev server:"
|
||||||
|
|||||||
@@ -65,7 +65,18 @@ data:
|
|||||||
# capability — see operator notes in README.md §5).
|
# capability — see operator notes in README.md §5).
|
||||||
- id: apple
|
- id: apple
|
||||||
provider: apple
|
provider: apple
|
||||||
|
# Production bundle id. Apple issues id_tokens with
|
||||||
|
# `aud` = the requesting app's bundle id, so this is the
|
||||||
|
# primary audience Kratos verifies against.
|
||||||
client_id: com.myhoneydue.honeyDue
|
client_id: com.myhoneydue.honeyDue
|
||||||
|
# Debug builds out of Xcode use a `.dev` bundle id (see
|
||||||
|
# iosApp/honeyDue.xcodeproj — Debug config). Their id_tokens
|
||||||
|
# therefore have `aud: com.myhoneydue.honeyDue.dev`, which
|
||||||
|
# the primary client_id check rejects. Whitelist the dev
|
||||||
|
# audience so Apple Sign In works from a non-Release Xcode
|
||||||
|
# build without per-build Kratos reconfiguration.
|
||||||
|
additional_id_token_audiences:
|
||||||
|
- com.myhoneydue.honeyDue.dev
|
||||||
apple_team_id: X86BR9WTLD
|
apple_team_id: X86BR9WTLD
|
||||||
apple_private_key_id: HQD3NCF99C
|
apple_private_key_id: HQD3NCF99C
|
||||||
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
|
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
|
||||||
@@ -195,6 +206,10 @@ data:
|
|||||||
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
|
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
|
||||||
// returns the name on the very first authorization and not in the ID
|
// returns the name on the very first authorization and not in the ID
|
||||||
// token claims, so only email is mapped here.
|
// token claims, so only email is mapped here.
|
||||||
|
//
|
||||||
|
// NOTE: we intentionally do NOT carry Apple's email_verified across via
|
||||||
|
// verified_addresses. Product decision: every account-creation flow —
|
||||||
|
// including Sign in with Apple — must complete an email verification step.
|
||||||
local claims = std.extVar('claims');
|
local claims = std.extVar('claims');
|
||||||
{
|
{
|
||||||
identity: {
|
identity: {
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ func noStore(c echo.Context) {
|
|||||||
c.Response().Header().Set("Cache-Control", "no-store")
|
c.Response().Header().Set("Cache-Control", "no-store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register handles POST /api/auth/register/ — creates a new password account.
|
||||||
|
//
|
||||||
|
// The identity is admin-created in Kratos with an unverified email and no
|
||||||
|
// auto-sent code (see services.AuthService.Register). The client logs in right
|
||||||
|
// after to get a session, then completes email verification. Returns 201 with
|
||||||
|
// no token; 409 if the email is taken; 400 on a weak password.
|
||||||
|
func (h *AuthHandler) Register(c echo.Context) error {
|
||||||
|
var req requests.RegisterRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_request_body")
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||||
|
}
|
||||||
|
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, map[string]string{
|
||||||
|
"message": "Account created. Please verify your email.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CurrentUser handles GET /api/auth/me/
|
// CurrentUser handles GET /api/auth/me/
|
||||||
func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
||||||
noStore(c)
|
noStore(c)
|
||||||
@@ -63,6 +85,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// user_profile.verified is a one-time mirror set at provision time
|
||||||
|
// (see middleware/kratos_auth.go::provision). Kratos remains the source
|
||||||
|
// of truth for email-verification state — it can flip from false → true
|
||||||
|
// the instant the user completes the verification flow, and nothing
|
||||||
|
// updates the local column. Override the response with the live value
|
||||||
|
// the Kratos auth middleware already stashed in context so /auth/me
|
||||||
|
// reflects current reality. Also opportunistically sync the DB mirror
|
||||||
|
// (best-effort, ignore error) so background queries that read the
|
||||||
|
// column see the same answer.
|
||||||
|
if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok {
|
||||||
|
mirrorStale := response.Profile != nil && response.Profile.Verified != verified
|
||||||
|
if response.Profile != nil {
|
||||||
|
response.Profile.Verified = verified
|
||||||
|
}
|
||||||
|
if verified && mirrorStale {
|
||||||
|
_ = h.authService.MarkUserVerified(c.Request().Context(), user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
package kratos
|
package kratos
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,6 +21,16 @@ import (
|
|||||||
// or inactive — the caller should respond 401.
|
// or inactive — the caller should respond 401.
|
||||||
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
|
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
|
||||||
|
|
||||||
|
// ErrIdentityExists is returned by CreateIdentity when an identity with the
|
||||||
|
// same credential identifier (email) already exists — caller should respond 409.
|
||||||
|
var ErrIdentityExists = errors.New("kratos: identity already exists")
|
||||||
|
|
||||||
|
// ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the
|
||||||
|
// password against its policy (too short, breached, etc.) — caller responds 400.
|
||||||
|
type ErrInvalidCredentials struct{ Reason string }
|
||||||
|
|
||||||
|
func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason }
|
||||||
|
|
||||||
// Client talks to the Ory Kratos public and admin APIs.
|
// Client talks to the Ory Kratos public and admin APIs.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
publicURL string
|
publicURL string
|
||||||
@@ -112,6 +124,94 @@ func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Sess
|
|||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIdentity admin-creates a Kratos identity with a password credential and
|
||||||
|
// an UNVERIFIED email, returning the created identity (with its UUID).
|
||||||
|
//
|
||||||
|
// Why admin-create instead of the self-service registration flow: self-service
|
||||||
|
// registration always auto-emails a verification code to a flow whose id Kratos
|
||||||
|
// never returns to the client (verified 2026-06-03 — true with and without the
|
||||||
|
// session after-hook), so the client can never submit the user's code to the
|
||||||
|
// right flow. Admin creation runs no registration hooks and sends no email, so
|
||||||
|
// honeyDue drives verification explicitly afterward: the client starts its own
|
||||||
|
// verification flow (the single code) and the user enters it. The new identity
|
||||||
|
// is fully usable immediately — the client logs in right after to get a session
|
||||||
|
// (Kratos permits login for unverified identities; app access is gated on the
|
||||||
|
// verified flag, not on Kratos login).
|
||||||
|
//
|
||||||
|
// password is sent in cleartext over the in-cluster admin API (never exposed
|
||||||
|
// publicly); Kratos hashes it with the configured bcrypt cost and applies the
|
||||||
|
// password policy, returning 400 on a weak/breached password and 409 on a
|
||||||
|
// duplicate email.
|
||||||
|
func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) {
|
||||||
|
if c.adminURL == "" {
|
||||||
|
return nil, errors.New("kratos: admin URL not configured")
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"schema_id": "honeydue",
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"name": map[string]any{"first": firstName, "last": lastName},
|
||||||
|
},
|
||||||
|
"credentials": map[string]any{
|
||||||
|
"password": map[string]any{
|
||||||
|
"config": map[string]any{"password": password},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kratos create identity: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusCreated, http.StatusOK:
|
||||||
|
var id Identity
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("kratos create identity: decode: %w", err)
|
||||||
|
}
|
||||||
|
return &id, nil
|
||||||
|
case http.StatusConflict:
|
||||||
|
return nil, ErrIdentityExists
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, &ErrInvalidCredentials{Reason: kratosReason(body)}
|
||||||
|
default:
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kratosReason pulls a human-readable reason out of a Kratos error body
|
||||||
|
// ({"error":{"reason":"...","message":"..."}}), falling back to the raw body.
|
||||||
|
func kratosReason(body []byte) string {
|
||||||
|
var env struct {
|
||||||
|
Error struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &env) == nil {
|
||||||
|
if env.Error.Reason != "" {
|
||||||
|
return env.Error.Reason
|
||||||
|
}
|
||||||
|
if env.Error.Message != "" {
|
||||||
|
return env.Error.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
|
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
|
||||||
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
|
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
|
||||||
// the identity is already gone, which is the desired end state, so the call
|
// the identity is already gone, which is the desired end state, so the call
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ const (
|
|||||||
AuthUserKey = "auth_user"
|
AuthUserKey = "auth_user"
|
||||||
// AuthTokenKey stores the raw session credential in the echo context.
|
// AuthTokenKey stores the raw session credential in the echo context.
|
||||||
AuthTokenKey = "auth_token"
|
AuthTokenKey = "auth_token"
|
||||||
// authVerifiedKey stores the Kratos email-verified flag in the context.
|
// AuthVerifiedKey stores the Kratos email-verified flag in the context.
|
||||||
authVerifiedKey = "auth_email_verified"
|
// Handlers can read this to override stale local mirrors like
|
||||||
|
// user_profile.verified with the live Kratos truth.
|
||||||
|
AuthVerifiedKey = "auth_email_verified"
|
||||||
|
|
||||||
// UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache.
|
// UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache.
|
||||||
UserCacheTTL = 5 * time.Minute
|
UserCacheTTL = 5 * time.Minute
|
||||||
@@ -76,7 +78,7 @@ func (m *KratosAuth) Authenticate() echo.MiddlewareFunc {
|
|||||||
}
|
}
|
||||||
c.Set(AuthUserKey, user)
|
c.Set(AuthUserKey, user)
|
||||||
c.Set(AuthTokenKey, cred)
|
c.Set(AuthTokenKey, cred)
|
||||||
c.Set(authVerifiedKey, verified)
|
c.Set(AuthVerifiedKey, verified)
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +92,7 @@ func (m *KratosAuth) OptionalAuthenticate() echo.MiddlewareFunc {
|
|||||||
if user, verified, cred, err := m.resolve(c); err == nil {
|
if user, verified, cred, err := m.resolve(c); err == nil {
|
||||||
c.Set(AuthUserKey, user)
|
c.Set(AuthUserKey, user)
|
||||||
c.Set(AuthTokenKey, cred)
|
c.Set(AuthTokenKey, cred)
|
||||||
c.Set(authVerifiedKey, verified)
|
c.Set(AuthVerifiedKey, verified)
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
@@ -105,7 +107,7 @@ func (m *KratosAuth) RequireVerified() echo.MiddlewareFunc {
|
|||||||
if GetAuthUser(c) == nil {
|
if GetAuthUser(c) == nil {
|
||||||
return apperrors.Unauthorized("error.not_authenticated")
|
return apperrors.Unauthorized("error.not_authenticated")
|
||||||
}
|
}
|
||||||
if verified, _ := c.Get(authVerifiedKey).(bool); !verified {
|
if verified, _ := c.Get(AuthVerifiedKey).(bool); !verified {
|
||||||
return apperrors.Forbidden("error.email_not_verified")
|
return apperrors.Forbidden("error.email_not_verified")
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkVerified sets user_userprofile.verified=true for the given user.
|
||||||
|
// Syncs the local mirror with Kratos's verifiable_addresses.verified after
|
||||||
|
// a successful verification flow. Idempotent — re-flipping an already-true
|
||||||
|
// row is a guarded no-op write.
|
||||||
|
func (r *UserRepository) MarkVerified(userID uint) error {
|
||||||
|
return r.db.Model(&models.UserProfile{}).
|
||||||
|
Where("user_id = ? AND verified = ?", userID, false).
|
||||||
|
Update("verified", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
// FindByIDWithProfile finds a user by ID with profile preloaded
|
// FindByIDWithProfile finds a user by ID with profile preloaded
|
||||||
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
|
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
|
||||||
var user models.User
|
var user models.User
|
||||||
|
|||||||
@@ -328,8 +328,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
|||||||
// API group
|
// API group
|
||||||
api := e.Group("/api")
|
api := e.Group("/api")
|
||||||
{
|
{
|
||||||
// Session lifecycle (login, register, logout, password reset) is
|
// Session lifecycle (login, logout, password reset, email verification)
|
||||||
// handled by Ory Kratos — no public auth routes in this service.
|
// is handled directly by Ory Kratos from the client. Registration is the
|
||||||
|
// exception: it goes through this endpoint, which admin-creates the
|
||||||
|
// Kratos identity so no verification email is auto-sent to an
|
||||||
|
// unreachable flow (see handlers.AuthHandler.Register). Public — the
|
||||||
|
// caller has no session yet.
|
||||||
|
api.POST("/auth/register/", authHandler.Register)
|
||||||
|
|
||||||
// Public data routes (no auth required)
|
// Public data routes (no auth required)
|
||||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
||||||
|
|||||||
@@ -48,6 +48,33 @@ func (s *AuthService) SetKratosClient(k *kratos.Client) {
|
|||||||
s.kratos = k
|
s.kratos = k
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register admin-creates a Kratos identity for a new password account with an
|
||||||
|
// unverified email and no auto-sent verification email (see
|
||||||
|
// kratos.Client.CreateIdentity for the rationale). The client logs in
|
||||||
|
// immediately afterward to obtain a session, then drives email verification
|
||||||
|
// explicitly. No local auth_user row is created here — the KratosAuth
|
||||||
|
// middleware lazily provisions it on the first authenticated request.
|
||||||
|
func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) error {
|
||||||
|
if s.kratos == nil {
|
||||||
|
return apperrors.Internal(errors.New("identity service unavailable"))
|
||||||
|
}
|
||||||
|
_, err := s.kratos.CreateIdentity(ctx, req.Email, req.FirstName, req.LastName, req.Password)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, kratos.ErrIdentityExists):
|
||||||
|
return apperrors.Conflict("error.email_already_taken")
|
||||||
|
default:
|
||||||
|
var invalid *kratos.ErrInvalidCredentials
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
return apperrors.BadRequest("error.password_complexity")
|
||||||
|
}
|
||||||
|
log.Error().Err(err).Msg("Kratos identity creation failed")
|
||||||
|
return apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetCurrentUser returns the current authenticated user with profile.
|
// GetCurrentUser returns the current authenticated user with profile.
|
||||||
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
|
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
|
||||||
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
||||||
@@ -65,6 +92,20 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*respons
|
|||||||
return &response, nil
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUserVerified flips the local user_profile.verified and auth_user.verified
|
||||||
|
// mirrors to true. Called opportunistically from CurrentUser when the live
|
||||||
|
// Kratos email-verified flag is true but the local mirror is stale (the user
|
||||||
|
// completed verification after the local row was provisioned). Best-effort:
|
||||||
|
// the canonical truth lives in Kratos's verifiable_addresses, so a failure
|
||||||
|
// here only means a brief stale read on background-only queries.
|
||||||
|
func (s *AuthService) MarkUserVerified(ctx context.Context, userID uint) error {
|
||||||
|
if err := s.userRepo.WithContext(ctx).MarkVerified(userID); err != nil {
|
||||||
|
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to mirror verified flag from Kratos")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProfile updates a user's profile fields.
|
// UpdateProfile updates a user's profile fields.
|
||||||
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
||||||
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
||||||
|
|||||||
Reference in New Issue
Block a user