diff --git a/db/hooks/users.go b/db/hooks/users.go index cb2fb2e4..6753c7e5 100644 --- a/db/hooks/users.go +++ b/db/hooks/users.go @@ -87,17 +87,6 @@ func UpdateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEve } } -func ChangeUserEmailHandler() func(e *core.RecordRequestEmailChangeRequestEvent) error { - return func(e *core.RecordRequestEmailChangeRequestEvent) error { - - e.Record.Set("email", e.NewEmail) - if err := e.App.Save(e.Record); err != nil { - return err - } - return nil - } -} - func createDefaultUserSettings(app core.App, userId string) error { collection, err := app.FindCollectionByNameOrId("settings") if err != nil { diff --git a/db/main.go b/db/main.go index 0056f441..aa1a1800 100644 --- a/db/main.go +++ b/db/main.go @@ -88,7 +88,6 @@ func registerMigrations(app *pocketbase.PocketBase) { func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) { app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client)) app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client)) - app.OnRecordRequestEmailChangeRequest("users").BindFunc(hooks.ChangeUserEmailHandler()) app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client)) app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client)) @@ -155,6 +154,7 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { se.Router.GET("/health", routes.Health) se.Router.POST("/auth/token", routes.AuthToken) + se.Router.POST("/user/email", routes.UserEmailChange) se.Router.POST("/waypoint/cluster", routes.WaypointCluster) se.Router.POST("/trail-merge/suggest", routes.TrailMergeSuggest) diff --git a/db/routes/user_email_change.go b/db/routes/user_email_change.go new file mode 100644 index 00000000..a986e30f --- /dev/null +++ b/db/routes/user_email_change.go @@ -0,0 +1,62 @@ +package routes + +import ( + "errors" + "net/http" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/tools/routine" +) + +func UserEmailChange(e *core.RequestEvent) error { + if e.Auth == nil { + return apis.NewUnauthorizedError("Authentication required", nil) + } + + var data struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := e.BindBody(&data); err != nil { + return apis.NewBadRequestError("Failed to read request data", err) + } + if data.Email == "" { + return apis.NewBadRequestError("Email is required", nil) + } + if data.Password == "" { + return apis.NewBadRequestError("Current password is required", nil) + } + if !e.Auth.ValidatePassword(data.Password) { + return apis.NewBadRequestError("Invalid password", nil) + } + + e.Auth.Set("email", data.Email) + e.Auth.Set("verified", false) + if err := e.App.Save(e.Auth); err != nil { + var verr validation.Errors + if errors.As(err, &verr) { + return apis.NewBadRequestError("Validation failed", verr) + } + return err + } + + app := e.App + routine.FireAndForget(func() { + if err := mails.SendRecordVerification(app, e.Auth); err != nil { + app.Logger().Error("Failed to send verification email", "error", err) + } + }) + + token, err := e.Auth.NewAuthToken() + if err != nil { + return err + } + + return e.JSON(http.StatusOK, map[string]any{ + "token": token, + "record": e.Auth, + }) +} diff --git a/web/src/lib/components/settings/email_modal.svelte b/web/src/lib/components/settings/email_modal.svelte index 3c9be737..9e84d908 100644 --- a/web/src/lib/components/settings/email_modal.svelte +++ b/web/src/lib/components/settings/email_modal.svelte @@ -9,7 +9,7 @@ interface Props { email?: string; - onsave?: (email: string) => void; + onsave?: (email: string, currentPassword: string) => void; } let { email = "", onsave }: Props = $props(); @@ -18,24 +18,28 @@ export function openModal() { setFields("email", email); + setFields("currentPassword", ""); setErrors("email", []); + setErrors("currentPassword", []); modal.openModal(); } const { form, errors, setFields, setErrors } = createForm<{ email: string; + currentPassword: string; }>({ - initialValues: { email: untrack(() => email) }, + initialValues: { email: untrack(() => email), currentPassword: "" }, extend: validator({ schema: z.object({ email: z .string() .min(1, "required") .email("not-a-valid-email-address"), + currentPassword: z.string().min(1, "required"), }), }), onSubmit: async (form) => { - onsave?.(form.email); + onsave?.(form.email, form.currentPassword); modal.closeModal!(); }, }); @@ -48,8 +52,9 @@ bind:this={modal} > {#snippet content()} -
{/snippet} {#snippet footer()} diff --git a/web/src/lib/stores/user_store.ts b/web/src/lib/stores/user_store.ts index 7b4f46d6..7cdc2250 100644 --- a/web/src/lib/stores/user_store.ts +++ b/web/src/lib/stores/user_store.ts @@ -78,9 +78,10 @@ export async function logout() { } export async function users_update(user: User | { [K in keyof User]?: User[K] }, avatar?: File) { + const { email: _email, ...payload } = user as any; let r = await fetch('/api/v1/user/' + user.id, { method: 'POST', - body: JSON.stringify(user) + body: JSON.stringify(payload) }) if (!r.ok) { @@ -121,6 +122,22 @@ export async function users_update(user: User | { [K in keyof User]?: User[K] }, currentUser.set(merged); } +export async function users_update_email(userId: string, email: string, currentPassword: string) { + const r = await fetch(`/api/v1/user/${userId}/email`, { + method: 'POST', + body: JSON.stringify({ email, currentPassword }), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const model: User = await r.json(); + const existing = get(currentUser); + currentUser.set({ ...(existing ?? {}), ...model } as User); +} + export async function users_delete(user: User) { const r = await fetch('/api/v1/user/' + user.id, { method: 'DELETE', diff --git a/web/src/routes/api/v1/user/[id]/+server.ts b/web/src/routes/api/v1/user/[id]/+server.ts index 40bd7692..d89d455a 100644 --- a/web/src/routes/api/v1/user/[id]/+server.ts +++ b/web/src/routes/api/v1/user/[id]/+server.ts @@ -83,19 +83,23 @@ export async function POST(event: RequestEvent) { const params = event.params const safeParams = RecordIdSchema.parse(params); - const safeData = UserUpdateSchema.parse(data); + if (safeParams.id !== event.locals.pb.authStore.record!.id) { + return json({ message: 'Forbidden' }, { status: 403 }); + } - if (safeData.email && safeData.email != event.locals.pb.authStore.record!.email) { - const r = await event.locals.pb.collection('users').requestEmailChange(safeData.email); - event.locals.pb.authStore.record!.email = safeData.email; + if (data.email !== undefined) { + return json({ message: 'Use POST /api/v1/user/{id}/email for email changes' }, { status: 400 }); } - const r = await event.locals.pb.collection('users').update