Add signup and email templates for user registration and password reset
- Created a new HTML file for the signup page at `backend/spa/signup/index.html`. - Added HTML and plain text templates for password reset emails at `backend/templates/mailTemplates/password_reset.html.tmpl` and `backend/templates/mailTemplates/password_reset.txt.tmpl`. - Added HTML and plain text templates for registration confirmation emails at `backend/templates/mailTemplates/registration.html.tmpl` and `backend/templates/mailTemplates/registration.txt.tmpl`.
|
|
@ -2,10 +2,3 @@
|
||||||
|
|
||||||
bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public
|
bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public
|
||||||
|
|
||||||
|
|
||||||
internal
|
|
||||||
auth
|
|
||||||
model
|
|
||||||
controller
|
|
||||||
service
|
|
||||||
endpoint
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//
|
//
|
||||||
// This file was generated by github.com/millevolte/ts-rpc
|
// This file was generated by github.com/millevolte/ts-rpc
|
||||||
//
|
//
|
||||||
// Apr 05, 2026 17:08:11 UTC
|
// Apr 05, 2026 20:12:24 UTC
|
||||||
//
|
//
|
||||||
|
|
||||||
export interface ApiRestResponse {
|
export interface ApiRestResponse {
|
||||||
|
|
@ -281,11 +281,178 @@ export type Nullable<T> = T | null;
|
||||||
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
|
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
|
||||||
|
|
||||||
//
|
//
|
||||||
// package model
|
// package systemUtils
|
||||||
//
|
//
|
||||||
|
|
||||||
export interface RefreshRequest {
|
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
||||||
refresh_token: string;
|
// internal/systemUtils/routes.go Line: 37
|
||||||
|
export const metrics = async (): Promise<{
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/metrics")) as {
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
|
||||||
|
// internal/systemUtils/routes.go Line: 48
|
||||||
|
export const mailDebug = async (): Promise<{
|
||||||
|
data: MailDebugItem[];
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/maildebug")) as {
|
||||||
|
data: MailDebugItem[];
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||||
|
// internal/systemUtils/routes.go Line: 34
|
||||||
|
export const health = async (): Promise<{
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/health")) as {
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MailDebugItem {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// package admin
|
||||||
|
//
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
||||||
|
// internal/admin/routes.go Line: 12
|
||||||
|
|
||||||
|
export const listUsers = async (
|
||||||
|
data: ListUsersRequest,
|
||||||
|
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/admin/users", data)) as {
|
||||||
|
data: UserShort[];
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||||
|
// internal/admin/routes.go Line: 16
|
||||||
|
|
||||||
|
export const blockUser = async (
|
||||||
|
data: BlockUserRequest,
|
||||||
|
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||||
|
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BlockUserRequest {
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListUsersRequest {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// package auth
|
||||||
|
//
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
||||||
|
// internal/auth/routes.go Line: 23
|
||||||
|
|
||||||
|
export const refresh = async (
|
||||||
|
data: RefreshRequest,
|
||||||
|
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/refresh", data)) as {
|
||||||
|
data: TokenPair;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
||||||
|
// internal/auth/routes.go Line: 26
|
||||||
|
export const me = async (): Promise<{
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/auth/me")) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
||||||
|
// internal/auth/routes.go Line: 29
|
||||||
|
|
||||||
|
export const register = async (
|
||||||
|
data: UserCreateInput,
|
||||||
|
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/register", data)) as {
|
||||||
|
data: UserShort;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
||||||
|
// internal/auth/routes.go Line: 32
|
||||||
|
|
||||||
|
export const forgotPassword = async (
|
||||||
|
data: ForgotPasswordRequest,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/password/forgot", data)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
||||||
|
// internal/auth/routes.go Line: 35
|
||||||
|
|
||||||
|
export const resetPassword = async (
|
||||||
|
data: ResetPasswordRequest,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/password/reset", data)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
|
||||||
|
// internal/auth/routes.go Line: 38
|
||||||
|
|
||||||
|
export const validToken = async (
|
||||||
|
data: string,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/password/valid", data)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
|
||||||
|
// internal/auth/routes.go Line: 20
|
||||||
|
|
||||||
|
export const login = async (
|
||||||
|
data: LoginRequest,
|
||||||
|
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/auth/login", data)) as {
|
||||||
|
data: TokenPair;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
|
|
@ -302,27 +469,60 @@ export interface LoginRequest {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResetPasswordRequest {
|
export interface RefreshRequest {
|
||||||
token: string;
|
refresh_token: string;
|
||||||
password: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// package controllers
|
// package user
|
||||||
//
|
//
|
||||||
|
|
||||||
export interface BlockUserRequest {
|
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
||||||
action: string;
|
// internal/user/routes.go Line: 18
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListUsersRequest {
|
export const updateUser = async (
|
||||||
page: number;
|
data: UpdateUserRequest,
|
||||||
pageSize: number;
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
}
|
return (await api.PUT("/users/:uuid", data)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface SimpleResponse {
|
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
||||||
message: string;
|
// internal/user/routes.go Line: 21
|
||||||
}
|
|
||||||
|
export const deleteUser = async (
|
||||||
|
uuid: string,
|
||||||
|
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.DELETE(`/users/${uuid}`)) as {
|
||||||
|
data: SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
||||||
|
// internal/user/routes.go Line: 12
|
||||||
|
export const getUser = async (
|
||||||
|
uuid: string,
|
||||||
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
|
return (await api.GET(`/users/${uuid}`)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
||||||
|
// internal/user/routes.go Line: 15
|
||||||
|
|
||||||
|
export const createUser = async (
|
||||||
|
data: UserCreateInput,
|
||||||
|
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/users", data)) as {
|
||||||
|
data: UserProfile;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -337,115 +537,16 @@ export interface UpdateUserRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// package routes
|
// package responses
|
||||||
//
|
//
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
export interface SimpleResponse {
|
||||||
// internal/http/routes/system_routes.go Line: 37
|
message: string;
|
||||||
export const metrics = async (): Promise<{
|
}
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/metrics")) as {
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
|
//
|
||||||
// internal/http/routes/system_routes.go Line: 48
|
// package routes
|
||||||
export const mailDebug = async (): Promise<{
|
//
|
||||||
data: MailDebugItem[];
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/maildebug")) as {
|
|
||||||
data: MailDebugItem[];
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
|
||||||
// internal/http/routes/user_routes.go Line: 13
|
|
||||||
export const getUser = async (
|
|
||||||
uuid: string,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.GET(`/users/${uuid}`)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
|
|
||||||
// internal/http/routes/user_routes.go Line: 19
|
|
||||||
|
|
||||||
export const updateUser = async (
|
|
||||||
data: UpdateUserRequest,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.PUT("/users/:uuid", data)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
|
|
||||||
// internal/http/routes/admin_routes.go Line: 12
|
|
||||||
|
|
||||||
export const listUsers = async (
|
|
||||||
data: ListUsersRequest,
|
|
||||||
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/admin/users", data)) as {
|
|
||||||
data: UserShort[];
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=controllers.BlockUserRequest; response=models.UserShort
|
|
||||||
// internal/http/routes/admin_routes.go Line: 15
|
|
||||||
|
|
||||||
export const blockUser = async (
|
|
||||||
data: BlockUserRequest,
|
|
||||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
|
||||||
return (await api.PUT("/admin/users/:uuid/block", data)) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
|
|
||||||
// internal/http/routes/user_routes.go Line: 16
|
|
||||||
|
|
||||||
export const createUser = async (
|
|
||||||
data: UserCreateInput,
|
|
||||||
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/users", data)) as {
|
|
||||||
data: UserProfile;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
|
||||||
// internal/http/routes/user_routes.go Line: 22
|
|
||||||
|
|
||||||
export const deleteUser = async (
|
|
||||||
uuid: string,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.DELETE(`/users/${uuid}`)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
|
||||||
// internal/http/routes/system_routes.go Line: 34
|
|
||||||
export const health = async (): Promise<{
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/health")) as {
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FormRequest {
|
export interface FormRequest {
|
||||||
req: string;
|
req: string;
|
||||||
|
|
@ -456,99 +557,6 @@ export interface FormResponse {
|
||||||
test: string;
|
test: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MailDebugItem {
|
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// package endpoint
|
|
||||||
//
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
|
|
||||||
// internal/auth/endpoint/routes.go Line: 34
|
|
||||||
|
|
||||||
export const forgotPassword = async (
|
|
||||||
data: ForgotPasswordRequest,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/password/forgot", data)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
|
|
||||||
// internal/auth/endpoint/routes.go Line: 37
|
|
||||||
|
|
||||||
export const resetPassword = async (
|
|
||||||
data: ResetPasswordRequest,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/password/reset", data)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
|
|
||||||
// internal/auth/endpoint/routes.go Line: 40
|
|
||||||
|
|
||||||
export const validToken = async (
|
|
||||||
data: string,
|
|
||||||
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/password/valid", data)) as {
|
|
||||||
data: SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
|
|
||||||
// internal/auth/endpoint/routes.go Line: 22
|
|
||||||
|
|
||||||
export const login = async (
|
|
||||||
data: LoginRequest,
|
|
||||||
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/login", data)) as {
|
|
||||||
data: TokenPair;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
|
||||||
// internal/auth/endpoint/routes.go Line: 25
|
|
||||||
|
|
||||||
export const refresh = async (
|
|
||||||
data: RefreshRequest,
|
|
||||||
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/refresh", data)) as {
|
|
||||||
data: TokenPair;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
|
||||||
// internal/auth/endpoint/routes.go Line: 28
|
|
||||||
export const me = async (): Promise<{
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/auth/me")) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
|
||||||
// internal/auth/endpoint/routes.go Line: 31
|
|
||||||
|
|
||||||
export const register = async (
|
|
||||||
data: UserCreateInput,
|
|
||||||
): Promise<{ data: UserShort; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/auth/register", data)) as {
|
|
||||||
data: UserShort;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// package models
|
// package models
|
||||||
//
|
//
|
||||||
|
|
@ -565,17 +573,6 @@ export interface UserCreateInput {
|
||||||
preferences: Nullable<UserPreferencesShort>;
|
preferences: Nullable<UserPreferencesShort>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDetailsShort {
|
|
||||||
title: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
zipCode: string;
|
|
||||||
country: string;
|
|
||||||
phone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferencesShort {
|
export interface UserPreferencesShort {
|
||||||
useIdle: boolean;
|
useIdle: boolean;
|
||||||
idleTimeout: number;
|
idleTimeout: number;
|
||||||
|
|
@ -587,6 +584,17 @@ export interface UserPreferencesShort {
|
||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDetailsShort {
|
||||||
|
title: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
zipCode: string;
|
||||||
|
country: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserShort {
|
export interface UserShort {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -598,14 +606,14 @@ export interface UserShort {
|
||||||
avatar: Nullable<string>;
|
avatar: Nullable<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRoles = string[];
|
|
||||||
|
|
||||||
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
|
||||||
|
|
||||||
export type UserTypes = string[];
|
export type UserTypes = string[];
|
||||||
|
|
||||||
export type UsersShort = UserShort[];
|
export type UsersShort = UserShort[];
|
||||||
|
|
||||||
|
export type UserRoles = string[];
|
||||||
|
|
||||||
|
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
|
||||||
|
|
||||||
export const EnumUserStatus = {
|
export const EnumUserStatus = {
|
||||||
UserStatusPending: "pending",
|
UserStatusPending: "pending",
|
||||||
UserStatusActive: "active",
|
UserStatusActive: "active",
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,13 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
authmodel "server/internal/auth/model"
|
"server/internal/auth"
|
||||||
authservice "server/internal/auth/service"
|
"server/internal/authorization"
|
||||||
"server/internal/config"
|
"server/internal/config"
|
||||||
"server/internal/db"
|
"server/internal/db"
|
||||||
"server/internal/http/controllers"
|
"server/internal/routes"
|
||||||
"server/internal/http/routes"
|
|
||||||
"server/internal/mail"
|
"server/internal/mail"
|
||||||
"server/internal/roles"
|
|
||||||
"server/internal/seed"
|
"server/internal/seed"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
@ -55,7 +54,7 @@ func main() {
|
||||||
log.Fatalf("init db: %v", err)
|
log.Fatalf("init db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authService, err := authservice.New(authmodel.Config{
|
authService, err := auth.NewAuthService(auth.Config{
|
||||||
Secret: cfg.Auth.Secret,
|
Secret: cfg.Auth.Secret,
|
||||||
Issuer: cfg.Auth.Issuer,
|
Issuer: cfg.Auth.Issuer,
|
||||||
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
|
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
|
||||||
|
|
@ -84,19 +83,6 @@ func main() {
|
||||||
log.Fatalf("setup mail: %v", err)
|
log.Fatalf("setup mail: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
roleConfigPath := cfg.RolesConfigPath
|
|
||||||
if envRoleConfig := os.Getenv("ROLES_CONFIG_PATH"); envRoleConfig != "" {
|
|
||||||
roleConfigPath = envRoleConfig
|
|
||||||
}
|
|
||||||
if roleConfigPath == "" {
|
|
||||||
roleConfigPath = envOrDefault("ROLES_CONFIG_PATH", "configs/roles.json")
|
|
||||||
}
|
|
||||||
roleResolver, err := controllers.LoadRoleConfig(roleConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load role config: %v", err)
|
|
||||||
}
|
|
||||||
roles.CheckUserRoleConsistency(dbConn, roleResolver)
|
|
||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: cfg.AppName,
|
AppName: cfg.AppName,
|
||||||
ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
|
ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
|
||||||
|
|
@ -139,7 +125,8 @@ func main() {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Use(controllers.RequireEndpointPermission(roleResolver, authService))
|
app.Use(authorization.RequireEndpointPermission(authService, dbConn))
|
||||||
|
|
||||||
routes.Register(app, authService, mailService)
|
routes.Register(app, authService, mailService)
|
||||||
|
|
||||||
port := envOrDefault("PORT", "3000")
|
port := envOrDefault("PORT", "3000")
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"mode": "file",
|
"mode": "file",
|
||||||
"from": "noreply@example.local",
|
"from": "noreply@example.local",
|
||||||
"debug_dir": "data/mail-debug",
|
"debug_dir": "data/mail-debug",
|
||||||
"templates_dir": "internal/http/templates",
|
"templates_dir": "templates",
|
||||||
|
"mail_templates_dir": "templates/mailTemplates",
|
||||||
"frontend_base_url": "http://localhost:9000",
|
"frontend_base_url": "http://localhost:9000",
|
||||||
"reset_password_path": "/#reset-password",
|
"reset_password_path": "/#reset-password",
|
||||||
"smtp": {
|
"smtp": {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package controllers
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -7,7 +7,10 @@ import (
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"server/internal/helpers"
|
||||||
"server/internal/models"
|
"server/internal/models"
|
||||||
|
"server/internal/responses"
|
||||||
|
"server/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminController struct{}
|
type AdminController struct{}
|
||||||
|
|
@ -33,7 +36,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := validateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Page <= 0 {
|
if req.Page <= 0 {
|
||||||
|
|
@ -43,7 +46,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
|
||||||
req.PageSize = 20
|
req.PageSize = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -79,11 +82,11 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := validateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -115,5 +118,5 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(success(models.ToUserShort(&user)))
|
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"server/internal/authorization"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterAdminRoutes(app *fiber.App) {
|
||||||
|
adminController := NewAdminController()
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
|
||||||
|
app.Post("/admin/users", adminController.ListUsers)
|
||||||
|
authorization.RegisterEndpoint("POST/admin/users", int(authorization.AdminPermission))
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
|
||||||
|
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
|
||||||
|
authorization.RegisterEndpoint("PUT/admin/users/:uuid/block", int(authorization.AdminPermission))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package controller
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
|
@ -6,26 +6,27 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"server/internal/helpers"
|
||||||
|
"server/internal/mail"
|
||||||
|
"server/internal/models"
|
||||||
|
"server/internal/responses"
|
||||||
|
"server/internal/tokens"
|
||||||
|
"server/internal/validation"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
authmodel "server/internal/auth/model"
|
|
||||||
authservice "server/internal/auth/service"
|
|
||||||
"server/internal/http/controllers"
|
|
||||||
"server/internal/mail"
|
|
||||||
"server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthController struct {
|
type AuthController struct {
|
||||||
authService *authservice.Service
|
authService *AuthService
|
||||||
mailService *mail.Service
|
mailService *mail.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(authService *authservice.Service, mailService *mail.Service) *AuthController {
|
func New(authService *AuthService, mailService *mail.Service) *AuthController {
|
||||||
return &AuthController{
|
return &AuthController{
|
||||||
authService: authService,
|
authService: authService,
|
||||||
mailService: mailService,
|
mailService: mailService,
|
||||||
|
|
@ -34,15 +35,15 @@ func New(authService *authservice.Service, mailService *mail.Service) *AuthContr
|
||||||
|
|
||||||
// Login authenticates a user and issues an access/refresh token pair.
|
// Login authenticates a user and issues an access/refresh token pair.
|
||||||
func (ac *AuthController) Login(c fiber.Ctx) error {
|
func (ac *AuthController) Login(c fiber.Ctx) error {
|
||||||
var req authmodel.LoginRequest
|
var req LoginRequest
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := controllers.ValidateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +55,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
|
||||||
}
|
}
|
||||||
match, err := authservice.VerifyPassword(user.Password, req.Password)
|
match, err := VerifyPassword(user.Password, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +63,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := ac.authService.GenerateTokenPair(user.Email)
|
token, err := ac.authService.GenerateTokenPair(user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
||||||
}
|
}
|
||||||
|
|
@ -76,8 +77,8 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
|
||||||
session := models.Session{
|
session := models.Session{
|
||||||
UserID: &userID,
|
UserID: &userID,
|
||||||
Username: user.Email,
|
Username: user.Email,
|
||||||
AccessTokenHash: controllers.HashToken(tokens.AccessToken),
|
AccessTokenHash: tokens.HashToken(token.AccessToken),
|
||||||
RefreshTokenHash: controllers.HashToken(tokens.RefreshToken),
|
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
|
||||||
ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
|
ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
|
||||||
IPAddress: c.IP(),
|
IPAddress: c.IP(),
|
||||||
UserAgent: c.Get("User-Agent"),
|
UserAgent: c.Get("User-Agent"),
|
||||||
|
|
@ -87,14 +88,14 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Response().Header.Set("Auth-Token", tokens.AccessToken)
|
c.Response().Header.Set("Auth-Token", token.AccessToken)
|
||||||
|
|
||||||
return c.JSON(controllers.Success(tokens))
|
return c.JSON(responses.Success(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh renews an access/refresh token pair using a valid refresh token.
|
// Refresh renews an access/refresh token pair using a valid refresh token.
|
||||||
func (ac *AuthController) Refresh(c fiber.Ctx) error {
|
func (ac *AuthController) Refresh(c fiber.Ctx) error {
|
||||||
var req authmodel.RefreshRequest
|
var req RefreshRequest
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
|
|
@ -106,30 +107,7 @@ func (ac *AuthController) Refresh(c fiber.Ctx) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||||
}
|
}
|
||||||
return c.JSON(controllers.Success(tokens))
|
return c.JSON(responses.Success(tokens))
|
||||||
}
|
|
||||||
|
|
||||||
// Me returns the authenticated user's profile (short format).
|
|
||||||
func (ac *AuthController) Me(c fiber.Ctx) error {
|
|
||||||
claims, ok := authservice.ClaimsFromCtx(c)
|
|
||||||
if !ok {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(controllers.Success(models.ToUserShort(&user)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a new user with optional roles/types/preferences.
|
// Register creates a new user with optional roles/types/preferences.
|
||||||
|
|
@ -138,11 +116,11 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := controllers.ValidateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +133,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
hashedPassword, err := authservice.HashPassword(req.Password)
|
hashedPassword, err := HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +161,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
|
||||||
}(),
|
}(),
|
||||||
Avatar: req.Avatar,
|
Avatar: req.Avatar,
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
Details: controllers.ToUserDetails(req.Details),
|
Details: helpers.ToUserDetails(req.Details),
|
||||||
Preferences: func() *models.UserPreferences {
|
Preferences: func() *models.UserPreferences {
|
||||||
if req.Preferences == nil {
|
if req.Preferences == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -220,19 +198,19 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(controllers.Success(models.ToUserShort(&user)))
|
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserShort(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
||||||
var req authmodel.ForgotPasswordRequest
|
var req ForgotPasswordRequest
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := controllers.ValidateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -240,13 +218,13 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return c.JSON(controllers.Success(fiber.Map{"sent": true}))
|
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
||||||
}
|
}
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status == models.UserStatusDisabled {
|
if user.Status == models.UserStatusDisabled {
|
||||||
return c.JSON(controllers.Success(fiber.Map{"sent": true}))
|
return c.JSON(responses.Success(fiber.Map{"sent": true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
resetToken, err := generateSecureToken()
|
resetToken, err := generateSecureToken()
|
||||||
|
|
@ -257,7 +235,7 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
record := models.PasswordResetToken{
|
record := models.PasswordResetToken{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TokenHash: controllers.HashToken(resetToken),
|
TokenHash: tokens.HashToken(resetToken),
|
||||||
ExpiresAt: now.Add(30 * time.Minute),
|
ExpiresAt: now.Add(30 * time.Minute),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
|
|
@ -288,30 +266,30 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(controllers.Success(controllers.SimpleResponse{Message: "password reset email sent"}))
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
|
func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
|
||||||
var req authmodel.ResetPasswordRequest
|
var req ResetPasswordRequest
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := controllers.ValidateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := authservice.HashPassword(req.Password)
|
hashedPassword, err := HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tokenHash := controllers.HashToken(req.Token)
|
tokenHash := tokens.HashToken(req.Token)
|
||||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
var resetToken models.PasswordResetToken
|
var resetToken models.PasswordResetToken
|
||||||
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
||||||
|
|
@ -351,7 +329,7 @@ func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(controllers.Success(controllers.SimpleResponse{Message: "password reset successful"}))
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *AuthController) ValidToken(c fiber.Ctx) error {
|
func (ac *AuthController) ValidToken(c fiber.Ctx) error {
|
||||||
|
|
@ -371,13 +349,13 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
return fiber.NewError(fiber.StatusBadRequest, "token is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := controllers.DBFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tokenHash := controllers.HashToken(token)
|
tokenHash := tokens.HashToken(token)
|
||||||
var resetToken models.PasswordResetToken
|
var resetToken models.PasswordResetToken
|
||||||
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
|
@ -390,7 +368,7 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(controllers.Success(controllers.SimpleResponse{Message: "valid reset token"}))
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateSecureToken() (string, error) {
|
func generateSecureToken() (string, error) {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package model
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -24,24 +24,3 @@ type TokenPair struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Permission int
|
|
||||||
|
|
||||||
type Role struct {
|
|
||||||
Name string
|
|
||||||
Permissions Permission
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
AdminPermission Permission = 0xff - (1<<iota - 1)
|
|
||||||
ManagerPermission
|
|
||||||
UserPermission
|
|
||||||
GuestPermission
|
|
||||||
)
|
|
||||||
|
|
||||||
var Roles = []Role{
|
|
||||||
{"admin", AdminPermission},
|
|
||||||
{"manager", ManagerPermission},
|
|
||||||
{"user", UserPermission},
|
|
||||||
{"guest", GuestPermission},
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package service
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package model
|
package auth
|
||||||
|
|
||||||
// Typescript: interface
|
// Typescript: interface
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
package endpoint
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
authcontroller "server/internal/auth/controller"
|
|
||||||
authservice "server/internal/auth/service"
|
|
||||||
"server/internal/mail"
|
"server/internal/mail"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/gofiber/fiber/v3/middleware/limiter"
|
"github.com/gofiber/fiber/v3/middleware/limiter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Register(app *fiber.App, authService *authservice.Service, mailService *mail.Service) {
|
func Register(app *fiber.App, authService *AuthService, mailService *mail.Service) {
|
||||||
authController := authcontroller.New(authService, mailService)
|
authController := New(authService, mailService)
|
||||||
authRateLimiter := limiter.New(limiter.Config{
|
authRateLimiter := limiter.New(limiter.Config{
|
||||||
Max: 10,
|
Max: 10,
|
||||||
Expiration: time.Minute,
|
Expiration: time.Minute,
|
||||||
|
|
@ -25,9 +23,6 @@ func Register(app *fiber.App, authService *authservice.Service, mailService *mai
|
||||||
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
|
||||||
app.Post("/auth/refresh", authService.Middleware(), authController.Refresh)
|
app.Post("/auth/refresh", authService.Middleware(), authController.Refresh)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
|
||||||
app.Get("/auth/me", authService.Middleware(), authController.Me)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
|
||||||
app.Post("/auth/register", authRateLimiter, authController.Register)
|
app.Post("/auth/register", authRateLimiter, authController.Register)
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package service
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -6,12 +6,10 @@ import (
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
authmodel "server/internal/auth/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type AuthService struct {
|
||||||
cfg authmodel.Config
|
cfg Config
|
||||||
secret []byte
|
secret []byte
|
||||||
accessExpiry time.Duration
|
accessExpiry time.Duration
|
||||||
refreshExpiry time.Duration
|
refreshExpiry time.Duration
|
||||||
|
|
@ -22,7 +20,7 @@ const (
|
||||||
tokenTypeRefresh = "refresh"
|
tokenTypeRefresh = "refresh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(cfg authmodel.Config) (*Service, error) {
|
func NewAuthService(cfg Config) (*AuthService, error) {
|
||||||
if cfg.Secret == "" {
|
if cfg.Secret == "" {
|
||||||
return nil, errors.New("jwt secret is required")
|
return nil, errors.New("jwt secret is required")
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +31,7 @@ func New(cfg authmodel.Config) (*Service, error) {
|
||||||
return nil, errors.New("refresh token expiry must be positive")
|
return nil, errors.New("refresh token expiry must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Service{
|
return &AuthService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
secret: []byte(cfg.Secret),
|
secret: []byte(cfg.Secret),
|
||||||
accessExpiry: cfg.AccessTokenExpiry,
|
accessExpiry: cfg.AccessTokenExpiry,
|
||||||
|
|
@ -41,32 +39,32 @@ func New(cfg authmodel.Config) (*Service, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GenerateTokenPair(username string) (authmodel.TokenPair, error) {
|
func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
|
||||||
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
|
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authmodel.TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry)
|
refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authmodel.TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return authmodel.TokenPair{
|
return TokenPair{
|
||||||
AccessToken: access,
|
AccessToken: access,
|
||||||
RefreshToken: refresh,
|
RefreshToken: refresh,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AccessExpiry() time.Duration {
|
func (s *AuthService) AccessExpiry() time.Duration {
|
||||||
return s.accessExpiry
|
return s.accessExpiry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RefreshExpiry() time.Duration {
|
func (s *AuthService) RefreshExpiry() time.Duration {
|
||||||
return s.refreshExpiry
|
return s.refreshExpiry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Middleware() fiber.Handler {
|
func (s *AuthService) Middleware() fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
tokenString := c.Get("Auth-Token")
|
tokenString := c.Get("Auth-Token")
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
|
|
@ -86,18 +84,18 @@ func (s *Service) Middleware() fiber.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Refresh(refreshToken string) (authmodel.TokenPair, error) {
|
func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
|
||||||
claims, err := s.parseToken(refreshToken)
|
claims, err := s.parseToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authmodel.TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
if claims.TokenType != tokenTypeRefresh {
|
if claims.TokenType != tokenTypeRefresh {
|
||||||
return authmodel.TokenPair{}, errors.New("refresh token required")
|
return TokenPair{}, errors.New("refresh token required")
|
||||||
}
|
}
|
||||||
return s.GenerateTokenPair(claims.Username)
|
return s.GenerateTokenPair(claims.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ValidateAccessToken(tokenString string) (*authmodel.Claims, error) {
|
func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
|
||||||
claims, err := s.parseToken(tokenString)
|
claims, err := s.parseToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -108,17 +106,17 @@ func (s *Service) ValidateAccessToken(tokenString string) (*authmodel.Claims, er
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClaimsFromCtx(c fiber.Ctx) (*authmodel.Claims, bool) {
|
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
|
||||||
val := c.Locals("authClaims")
|
val := c.Locals("authClaims")
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
claims, ok := val.(*authmodel.Claims)
|
claims, ok := val.(*Claims)
|
||||||
return claims, ok
|
return claims, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) parseToken(tokenString string) (*authmodel.Claims, error) {
|
func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
|
||||||
claims := &authmodel.Claims{}
|
claims := &Claims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fiber.ErrUnauthorized
|
return nil, fiber.ErrUnauthorized
|
||||||
|
|
@ -135,8 +133,8 @@ func (s *Service) parseToken(tokenString string) (*authmodel.Claims, error) {
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
|
func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
|
||||||
claims := authmodel.Claims{
|
claims := Claims{
|
||||||
Username: username,
|
Username: username,
|
||||||
TokenType: tokenType,
|
TokenType: tokenType,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package authorization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"server/internal/auth"
|
||||||
|
"server/internal/models"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Endpoints map[string]int
|
||||||
|
|
||||||
|
type Permission int
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
Name string
|
||||||
|
Permissions Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SuperAdminPermission Permission = 0b1111111111111111
|
||||||
|
AdminPermission Permission = 0b0111111111111111
|
||||||
|
ManagerPermission Permission = 0b0010111111111111
|
||||||
|
ContentCreatorPermission Permission = 0b0001111111111111
|
||||||
|
UserPermission Permission = 0b0000000000000011
|
||||||
|
GuestPermission Permission = 0b0000000000000001
|
||||||
|
)
|
||||||
|
|
||||||
|
var Roles = []Role{
|
||||||
|
{"superadmin", SuperAdminPermission},
|
||||||
|
{"admin", AdminPermission},
|
||||||
|
{"manager", ManagerPermission},
|
||||||
|
{"content_creator", ContentCreatorPermission},
|
||||||
|
{"user", UserPermission},
|
||||||
|
{"guest", GuestPermission},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Endpoints = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterEndpoint(key string, permission int) {
|
||||||
|
Endpoints[key] = permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireEndpointPermission enforces permission mapping defined in role config.
|
||||||
|
// If the endpoint is not configured, or mapped to "*", it allows the request.
|
||||||
|
func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path()))
|
||||||
|
perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())]
|
||||||
|
if perm == 0 {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := c.Get("Auth-Token")
|
||||||
|
if tokenString == "" {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authService.ValidateAccessToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||||
|
}
|
||||||
|
c.Locals("authClaims", claims)
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := dbConn.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// user need to have at least one role that satisfies the permission requirement
|
||||||
|
if user.Roles == nil {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ type MailConfig struct {
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
DebugDir string `json:"debug_dir"`
|
DebugDir string `json:"debug_dir"`
|
||||||
TemplatesDir string `json:"templates_dir"`
|
TemplatesDir string `json:"templates_dir"`
|
||||||
|
MailTemplatesDir string `json:"mail_templates_dir"`
|
||||||
FrontendBaseURL string `json:"frontend_base_url"`
|
FrontendBaseURL string `json:"frontend_base_url"`
|
||||||
ResetPasswordPath string `json:"reset_password_path"`
|
ResetPasswordPath string `json:"reset_password_path"`
|
||||||
SMTP SMTPMailConfig `json:"smtp"`
|
SMTP SMTPMailConfig `json:"smtp"`
|
||||||
|
|
@ -62,8 +63,8 @@ func LoadConfig(path string) (ServerConfig, error) {
|
||||||
if cfg.Mail.Mode == "" {
|
if cfg.Mail.Mode == "" {
|
||||||
cfg.Mail.Mode = "file"
|
cfg.Mail.Mode = "file"
|
||||||
}
|
}
|
||||||
if cfg.Mail.TemplatesDir == "" {
|
if cfg.Mail.MailTemplatesDir == "" {
|
||||||
cfg.Mail.TemplatesDir = "internal/http/templates"
|
cfg.Mail.MailTemplatesDir = "templates/mailTemplates"
|
||||||
}
|
}
|
||||||
if cfg.Mail.ResetPasswordPath == "" {
|
if cfg.Mail.ResetPasswordPath == "" {
|
||||||
cfg.Mail.ResetPasswordPath = "/#reset-password"
|
cfg.Mail.ResetPasswordPath = "/#reset-password"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package controllers
|
package helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"server/internal/models"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// dbFromCtx extracts *gorm.DB from Fiber context.
|
// dbFromCtx extracts *gorm.DB from Fiber context.
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
package controllers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
authservice "server/internal/auth/service"
|
|
||||||
"server/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RoleConfig struct {
|
|
||||||
Roles map[string][]string `json:"roles"`
|
|
||||||
Permissions map[string][]string `json:"permissions"`
|
|
||||||
Endpoints map[string]string `json:"endpoints"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoleResolver struct {
|
|
||||||
roleClosure map[string]map[string]struct{}
|
|
||||||
permMap map[string]map[string]struct{}
|
|
||||||
endpointPerm map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadRoleConfig(path string) (*RoleResolver, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read role config: %w", err)
|
|
||||||
}
|
|
||||||
var cfg RoleConfig
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse role config: %w", err)
|
|
||||||
}
|
|
||||||
res := &RoleResolver{
|
|
||||||
roleClosure: make(map[string]map[string]struct{}),
|
|
||||||
permMap: make(map[string]map[string]struct{}),
|
|
||||||
endpointPerm: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
for role := range cfg.Roles {
|
|
||||||
res.roleClosure[role] = make(map[string]struct{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute role closure (role implies itself).
|
|
||||||
var dfs func(string, map[string]struct{})
|
|
||||||
dfs = func(role string, seen map[string]struct{}) {
|
|
||||||
if _, ok := seen[role]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[role] = struct{}{}
|
|
||||||
if implied, ok := cfg.Roles[role]; ok {
|
|
||||||
for _, r := range implied {
|
|
||||||
dfs(r, seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for role := range cfg.Roles {
|
|
||||||
set := make(map[string]struct{})
|
|
||||||
set[role] = struct{}{}
|
|
||||||
dfs(role, set)
|
|
||||||
res.roleClosure[role] = set
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build permission map including inherited permissions.
|
|
||||||
for role := range cfg.Roles {
|
|
||||||
res.permMap[role] = make(map[string]struct{})
|
|
||||||
}
|
|
||||||
for role := range cfg.Roles {
|
|
||||||
closure := res.roleClosure[role]
|
|
||||||
for implied := range closure {
|
|
||||||
for _, p := range cfg.Permissions[implied] {
|
|
||||||
res.permMap[role][p] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalise endpoints to "METHOD /path".
|
|
||||||
for key, perm := range cfg.Endpoints {
|
|
||||||
parts := strings.SplitN(key, " ", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return nil, fmt.Errorf("invalid endpoint key %q", key)
|
|
||||||
}
|
|
||||||
method := strings.TrimSpace(strings.ToUpper(parts[0]))
|
|
||||||
path := strings.TrimSpace(parts[1])
|
|
||||||
res.endpointPerm[method+" "+path] = perm
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RoleResolver) HasRole(userRoles []string, required string) bool {
|
|
||||||
for _, ur := range userRoles {
|
|
||||||
if closure, ok := r.roleClosure[ur]; ok {
|
|
||||||
if _, present := closure[required]; present {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RoleResolver) HasPermission(userRoles []string, perm string) bool {
|
|
||||||
for _, ur := range userRoles {
|
|
||||||
if perms, ok := r.permMap[ur]; ok {
|
|
||||||
if _, present := perms[perm]; present {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RoleResolver) PermissionForEndpoint(method, path string) (string, bool) {
|
|
||||||
key := strings.ToUpper(method) + " " + path
|
|
||||||
perm, ok := r.endpointPerm[key]
|
|
||||||
return perm, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RoleResolver) RoleDefined(role string) bool {
|
|
||||||
_, ok := r.roleClosure[role]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireRole ensures the authenticated user has the specified role (with inheritance).
|
|
||||||
func RequireRole(resolver *RoleResolver, role string) fiber.Handler {
|
|
||||||
return func(c fiber.Ctx) error {
|
|
||||||
claims, ok := authservice.ClaimsFromCtx(c)
|
|
||||||
if !ok {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resolver.HasRole(user.Roles, role) {
|
|
||||||
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
|
||||||
}
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequirePermission ensures the authenticated user has the given permission.
|
|
||||||
func RequirePermission(resolver *RoleResolver, perm string) fiber.Handler {
|
|
||||||
return func(c fiber.Ctx) error {
|
|
||||||
claims, ok := authservice.ClaimsFromCtx(c)
|
|
||||||
if !ok {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resolver.HasPermission(user.Roles, perm) {
|
|
||||||
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
|
||||||
}
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireEndpointPermission enforces permission mapping defined in role config.
|
|
||||||
// If the endpoint is not configured, or mapped to "*", it allows the request.
|
|
||||||
func RequireEndpointPermission(resolver *RoleResolver, authService *authservice.Service) fiber.Handler {
|
|
||||||
return func(c fiber.Ctx) error {
|
|
||||||
perm, ok := resolver.PermissionForEndpoint(c.Method(), c.Path())
|
|
||||||
if !ok || perm == "*" {
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenString := c.Get("Auth-Token")
|
|
||||||
if tokenString == "" {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := authService.ValidateAccessToken(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
|
||||||
}
|
|
||||||
c.Locals("authClaims", claims)
|
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resolver.HasPermission(user.Roles, perm) {
|
|
||||||
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package controllers
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type SimpleResponse struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"server/internal/http/controllers"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerAdminRoutes(app *fiber.App) {
|
|
||||||
adminController := controllers.NewAdminController()
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
|
|
||||||
app.Post("/admin/users", adminController.ListUsers)
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=controllers.BlockUserRequest; response=models.UserShort
|
|
||||||
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
authendpoint "server/internal/auth/endpoint"
|
|
||||||
authservice "server/internal/auth/service"
|
|
||||||
"server/internal/mail"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type FormRequest struct {
|
|
||||||
Req string `json:"req"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typescript: interface
|
|
||||||
type FormResponse struct {
|
|
||||||
Test string `json:"test"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Register(app *fiber.App, authService *authservice.Service, mailService *mail.Service) {
|
|
||||||
registerSystemRoutes(app)
|
|
||||||
authendpoint.Register(app, authService, mailService)
|
|
||||||
registerUserRoutes(app, authService)
|
|
||||||
registerAdminRoutes(app)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
package controllers
|
package responses
|
||||||
|
|
||||||
import "github.com/gofiber/fiber/v3"
|
import "github.com/gofiber/fiber/v3"
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type SimpleResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
// success wraps a payload in the standard API envelope.
|
// success wraps a payload in the standard API envelope.
|
||||||
func success(data any) fiber.Map {
|
func success(data any) fiber.Map {
|
||||||
return fiber.Map{
|
return fiber.Map{
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
package roles
|
package roles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"server/internal/http/controllers"
|
|
||||||
"server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CheckUserRoleConsistency(db *gorm.DB, resolver *controllers.RoleResolver) {
|
func CheckUserRoleConsistency(db *gorm.DB) {
|
||||||
var list []models.User
|
|
||||||
if err := db.Select("email", "roles").Find(&list).Error; err != nil {
|
|
||||||
log.Printf("warning: cannot verify user roles: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, u := range list {
|
|
||||||
for _, r := range u.Roles {
|
|
||||||
if !resolver.RoleDefined(r) {
|
|
||||||
log.Printf("inconsistency: user %s has undefined role %q", u.Email, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"server/internal/admin"
|
||||||
|
"server/internal/auth"
|
||||||
|
"server/internal/mail"
|
||||||
|
"server/internal/systemUtils"
|
||||||
|
"server/internal/user"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type FormRequest struct {
|
||||||
|
Req string `json:"req"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typescript: interface
|
||||||
|
type FormResponse struct {
|
||||||
|
Test string `json:"test"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(app *fiber.App, authService *auth.AuthService, mailService *mail.Service) {
|
||||||
|
systemUtils.RegisterSystemRoutes(app)
|
||||||
|
auth.Register(app, authService, mailService)
|
||||||
|
user.RegisterUserRoutes(app, authService)
|
||||||
|
admin.RegisterAdminRoutes(app)
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/brianvoe/gofakeit/v6"
|
"github.com/brianvoe/gofakeit/v6"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
authservice "server/internal/auth/service"
|
"server/internal/auth"
|
||||||
"server/internal/models"
|
"server/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("generate password: %w", err)
|
return nil, nil, fmt.Errorf("generate password: %w", err)
|
||||||
}
|
}
|
||||||
passwordHash, err := authservice.HashPassword(pw)
|
passwordHash, err := auth.HashPassword(pw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("hash seed password: %w", err)
|
return nil, nil, fmt.Errorf("hash seed password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package routes
|
package systemUtils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const spaDistPath = "internal/http/static/spa"
|
const spaDistPath = "http/static/spa"
|
||||||
|
|
||||||
// Typescript: interface
|
// Typescript: interface
|
||||||
type MailDebugItem struct {
|
type MailDebugItem struct {
|
||||||
|
|
@ -30,7 +30,7 @@ func healthHandler(c fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerSystemRoutes(app *fiber.App) {
|
func RegisterSystemRoutes(app *fiber.App) {
|
||||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||||
app.Get("/health", healthHandler)
|
app.Get("/health", healthHandler)
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package controllers
|
package tokens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package controllers
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -9,8 +9,11 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
authservice "server/internal/auth/service"
|
"server/internal/auth"
|
||||||
|
"server/internal/helpers"
|
||||||
"server/internal/models"
|
"server/internal/models"
|
||||||
|
"server/internal/responses"
|
||||||
|
"server/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserController struct{}
|
type UserController struct{}
|
||||||
|
|
@ -38,7 +41,7 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.JSON(success(models.ToUserProfile(user)))
|
return c.JSON(responses.Success(models.ToUserProfile(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a user together with optional details and preferences.
|
// CreateUser creates a user together with optional details and preferences.
|
||||||
|
|
@ -47,11 +50,11 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := validateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +66,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := authservice.HashPassword(req.Password)
|
hashedPassword, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
|
||||||
}
|
}
|
||||||
|
|
@ -93,8 +96,8 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
}(),
|
}(),
|
||||||
Avatar: req.Avatar,
|
Avatar: req.Avatar,
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
Details: toUserDetails(req.Details),
|
Details: helpers.ToUserDetails(req.Details),
|
||||||
Preferences: toUserPreferences(req.Preferences),
|
Preferences: helpers.ToUserPreferences(req.Preferences),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +110,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserProfile(&user)))
|
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser replaces user fields and synchronizes details/preferences.
|
// UpdateUser replaces user fields and synchronizes details/preferences.
|
||||||
|
|
@ -116,11 +119,11 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := validateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -173,12 +176,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(success(models.ToUserProfile(user)))
|
return c.JSON(responses.Success(models.ToUserProfile(user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
||||||
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +203,7 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(success(SimpleResponse{Message: "user deleted"}))
|
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadUserByID(c fiber.Ctx) (*models.User, error) {
|
func loadUserByID(c fiber.Ctx) (*models.User, error) {
|
||||||
|
|
@ -209,7 +212,7 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +234,7 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := dbFromCtx(c)
|
db, err := helpers.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -304,3 +307,26 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
|
||||||
}
|
}
|
||||||
return tx.Save(&preferences).Error
|
return tx.Save(&preferences).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Me returns the authenticated user's profile (short format).
|
||||||
|
func (uc *UserController) Me(c fiber.Ctx) error {
|
||||||
|
claims, ok := auth.ClaimsFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := helpers.DBFromCtx(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusNotFound, "user not found")
|
||||||
|
}
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(responses.Success(models.ToUserShort(&user)))
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package routes
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
authservice "server/internal/auth/service"
|
"server/internal/auth"
|
||||||
"server/internal/http/controllers"
|
"server/internal/authorization"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerUserRoutes(app *fiber.App, authService *authservice.Service) {
|
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
|
||||||
userController := controllers.NewUserController()
|
userController := NewUserController()
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
|
||||||
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
|
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
|
||||||
|
|
@ -21,4 +21,8 @@ func registerUserRoutes(app *fiber.App, authService *authservice.Service) {
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
|
||||||
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
|
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
|
||||||
|
app.Get("/auth/me", authService.Middleware(), userController.Me)
|
||||||
|
authorization.RegisterEndpoint("GET/auth/me", int(authorization.UserPermission))
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package controllers
|
package validation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 448 KiB After Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |