Compare commits

...

6 Commits

Author SHA1 Message Date
fabio
cd858b0d8c feat: add notifications for profile updates, personal data changes, and password updates 2026-07-26 17:56:33 +02:00
fabio
7a2b80381d feat: add password update functionality for user profiles 2026-07-26 17:48:17 +02:00
fabio
72e7626122 feat: enhance profile status display and remove pagination 2026-07-26 17:29:11 +02:00
fabio
1367829f80 feat: implement password reset functionality
- Added PasswordResetPage component for user password reset.
- Integrated password reset link in LoginPage with a router link.
- Created API endpoints for requesting and confirming password resets.
- Added email templates for password reset notifications.
- Updated database schema to support password reset tokens.
- Enhanced ProfilePage and EditProfileDialog to include personal data fields.
- Introduced layout store to manage body dimensions for responsive design.
- Added validation for personal data fields in profile management.
2026-07-26 17:28:51 +02:00
fabio
167be9b0b3 feat: implement user profile management with avatars, login, and personal data
- Add avatar upload functionality with public URL access.
- Implement user login with email and password authentication.
- Create endpoints for fetching and updating the authenticated user's profile.
- Set up database migrations for user profiles, including email and role management.
- Introduce personal data management linked to user profiles.
- Add email verification process with welcome email templates.
2026-07-02 10:54:56 +02:00
fabio
2608ce6e60 Initial commit 2026-07-02 10:54:13 +02:00
109 changed files with 13319 additions and 107 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

837
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,837 @@
## Go style guide
MUST write valid Go v1.22+ code, best practices.
## Generated folders — do not edit
`encore.gen/` + `.encore/` CLI-regenerated; never edit.
`encore.app` at repo root = app manifest — CUE-formatted **text** file, not binary, despite `.app` extension. Read like any source file.
## Encore local MCP server
Local Encore MCP wired via `.mcp.json` (registered **`encore-local`**, tool prefix `mcp__encore-local__`). Prefer for live app introspection over `Read`/grep; use docs tools when unsure of Encore API surface.
### Tools
20 tools, all prefixed `mcp__encore-local__`. MCP client gets full catalog (names, descriptions, input schemas) via `tools/list` on session init — no need enumerate here.
### Important tools (runtime-only — no filesystem equivalent)
Do what `Glob`/`Grep`/`Read` can't. For static config (services, endpoints, topics, schemas), see "When NOT to use it" below.
- **`call_endpoint`** — args: `service`, `endpoint`, `method`, `path`, `payload` (JSON string with body/query/headers/path-params), optional `auth_token` / `auth_payload` / `correlation_id`. Optional `retry_until: { predicate, timeout_ms?, interval_ms?, fail_on_timeout? }` polls **server-side** until predicate matches — predicate = `status: 200` OR `body_path: { path: ".events.0.orderID", equals: 7 }` OR `body_jq: ".events | length > 0"` (minimal subset: `<path> | length <op> N` or `<path>` truthy). Use instead of agent-side polling loops for eventually-consistent reads. **auto-starts app** if not running. Use instead of `curl` to test endpoints.
- **`wait_for_subscription_message`** — args: `topic`, optional `subscription`, `timeout_ms` (default 10000), `since` (ISO/RFC3339), `match` (top-level key/value JSON filter, e.g. `{"CustomerID":"cust_42"}`). Blocks until the next message on the topic is fully processed by a subscription handler (returns or errors), then returns `{outcome, payload, duration_ms, handler_error, trace_id}`. Use to bridge async Pub/Sub work into a synchronous verify step — beats polling read-side endpoints + introspecting via `get_pubsub`/`get_traces`.
- **`query_database`** — args: `queries` (array of `{database, query}`). Runs SQL against named DBs, multiple queries one call. Beats `encore db conn-uri` + `psql` round-trips.
- **`get_traces`** — args: optional `service`, `endpoint`, `error` (`"true"`/`"false"`), `start_time` / `end_time` (ISO), `limit`. Returns recent request traces (timing, status, ids).
- **`get_trace_spans`** — args: `trace_ids` (array IDs from `get_traces`). Returns full per-span details for deep debugging.
- **`get_objects`** — args: `buckets` (array of bucket names). Lists objects + metadata in storage buckets.
- **`search_docs`** — args: `query`, optional `hits_per_page`, `page`, `facet_filters`. Algolia-backed Encore docs search.
- **`get_docs`** — args: `paths` (array of doc paths, e.g. `/docs/go/primitives/databases`). Fetches full doc pages found via `search_docs`.
### When to reach for it
| Situation | Tool |
|---|---|
| "What services / endpoints / databases exist?" | `get_metadata` once |
| "Test my new POST /orders endpoint" | `call_endpoint` (auto-starts app) |
| "Did the Pub/Sub handler run after I published?" | `wait_for_subscription_message` |
| "Endpoint output is eventually consistent (e.g. read-after-publish)" | `call_endpoint` with `retry_until` |
| "Why did last request fail?" | `get_traces` then `get_trace_spans` |
| "How does Encore API surface work?" | `search_docs` then `get_docs` |
- **MCP only for runtime data** (`call_endpoint`, `query_database`, `get_traces`, `get_objects`, `search_docs`/`get_docs`). For **static structure** declared in source (services, endpoints, topics, subscriptions, schemas, secrets, middleware, cron jobs), `Glob`+`Grep`+`Read` faster + more reliable than `get_*` tool.
### Cloud MCP (deployed environments)
If deployed Encore Cloud, also register `encore-cloud` via `claude mcp add --transport http encore-cloud https://api.encore.cloud/mcp` for prod traces / deploy state — `encore-local` only sees local app.
## Encore check (verify app builds + endpoints work)
`encore check` compiles, boots, health-checks app, optionally runs `curl` once healthy. Use instead of manual `encore run + healthz poll + curl`.
```bash
encore check # compile + boot only
encore check 'curl /ping' # GET a relative path
encore check 'curl /orders -X POST -d "{\"customer_id\":\"c1\",\"amount_cents\":1999}"' # POST with JSON body (flags after path)
encore check 'curl /a; curl /b' # chain in one quoted string
encore check 'curl /a' 'curl /b' # or as separate quoted args
encore check < commands.txt # one curl per line, piped from file
```
Rules embedded curl DSL:
- **Paths relative** (start with `/`). Use `curl /orders/1`, **not** `curl http://localhost:4000/orders/1` — host/port supplied by `encore check`. Absolute URL fails with `curl path "http..." must be relative`.
- **Path first, flags after.** `curl /orders -X POST -d '...'` works; `curl -X POST /orders ...` fails — parser reads first token after `curl` as path, rejects flags there (`curl path "-X" must be relative`).
- One curl per quoted command. Chain `;` inside one quoted string, pass multiple quoted args, or pipe file one curl per line.
## Pub/sub verification
- Delivery async + at-least-once + not order-preserving.
- For "most-recent first" by publish order, DO NOT sort by `created_at = NOW()` or subscription-side row id — both subscription-insert order, at-least-once can flip. Sort by column monotonic with publish (entity id from event payload, or publish-time sequence in event).
- Handler-only checks, skip infra: `et.Topic(T).PublishedMessages()`.
## Encore Go domain knowledge
### Application structure
Encore uses monorepo design — one app = entire backend. Enables distributed tracing + Encore Flow via unified application model. Supports monolith + microservices with monolith-style DX.
Directory structure:
/app-name
encore.app
service1/
migrations/
1_create_table.up.sql
service1.go
service1_test.go
service2/
service2.go
Sub-packages internal to services, cannot define APIs, used for helpers + code organization.
Large apps — group related services into system directories (logical groupings, no special runtime behavior):
/app-name
encore.app
system1/
service1/
service2/
system2/
service3/
### API definition
Create type-safe APIs from regular Go functions via //encore:api annotation.
Access controls:
- public: Accessible to anyone on internet
- private: Only accessible within app + via cron jobs
- auth: Public but requires valid auth
Function signatures:
func Foo(ctx context.Context, p *Params) (*Response, error) // full
func Foo(ctx context.Context) (*Response, error) // response only
func Foo(ctx context.Context, p *Params) error // request only
func Foo(ctx context.Context) error // minimal
Request/response data locations:
- header: Use `header` tag for HTTP headers
- query: Default GET/HEAD/DELETE, uses snake_case, supports basic types/slices
- body: Default other methods, uses `json` tag, supports complex types
Path parameters: Use :name for variables, *name for wildcards. Place at end of path.
Sensitive data:
- Field level: `encore:"sensitive"` tag, auto-redacted in tracing
- Endpoint level: Add `sensitive` to //encore:api annotation
Type support by location:
- headers/path: bool, numeric, string, time.Time, UUID, json.RawMessage
- query: All above plus lists
- body: All types including structs, maps, pointers
### Services
Service defined by creating at least one API within Go package. Package name = service name.
//encore:service annotation enables custom init + graceful shutdown:
type Service struct {
// Dependencies here
}
func initService() (*Service, error) {
// Initialization code
}
//encore:api public
func (s *Service) MyAPI(ctx context.Context) error {
// API implementation
}
Graceful shutdown via Shutdown method:
func (s *Service) Shutdown(force context.Context)
- Graceful phase: Several seconds for completion
- Forced phase: When force context canceled, terminate immediately
### Raw endpoints
Lower-level HTTP access (webhooks, WebSockets):
//encore:api public raw
func Webhook(w http.ResponseWriter, req *http.Request) {
// Process raw HTTP request
}
//encore:api public raw method=POST path=/webhook/:id
func Webhook(w http.ResponseWriter, req *http.Request) {
id := encore.CurrentRequest().PathParams.Get("id")
}
### SQL databases
Encore treats SQL databases as logical resources with native PostgreSQL support.
Create database:
var tododb = sqldb.NewDatabase("todo", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
Migration naming: number_description.up.sql (e.g., 1_create_table.up.sql)
Migrations folder structure:
service/
migrations/
1_create_table.up.sql
2_add_field.up.sql
service.go
Data operations:
// Insert
_, err := tododb.Exec(ctx, `
INSERT INTO todo_item (id, title, done)
VALUES ($1, $2, $3)
`, id, title, done)
// Query
err := tododb.QueryRow(ctx, `
SELECT id, title, done FROM todo_item LIMIT 1
`).Scan(&item.ID, &item.Title, &item.Done)
// Use errors.Is(err, sqldb.ErrNoRows) for no results
Always Scan into typed struct fields, never `interface{}` slots — compiler enforces column-to-field shape + catches drift between SELECT list + destination struct.
CLI commands:
- encore db shell database-name [--env=name] - Open psql shell
- encore db conn-uri database-name [--env=name] - Output connection string
- encore db proxy [--env=name] - Setup local connection proxy
### External databases
Existing databases — create dedicated package with lazy connection pool:
package externaldb
import (
"context"
"fmt"
"github.com/jackc/pgx/v4/pgxpool"
"go4.org/syncutil"
)
func Get(ctx context.Context) (*pgxpool.Pool, error) {
err := once.Do(func() error {
var err error
pool, err = setup(ctx)
return err
})
return pool, err
}
var (
once syncutil.Once
pool *pgxpool.Pool
)
var secrets struct {
ExternalDBPassword string
}
func setup(ctx context.Context) (*pgxpool.Pool, error) {
connString := fmt.Sprintf("postgresql://%s:%s@hostname:port/dbname?sslmode=require",
"user", secrets.ExternalDBPassword)
return pgxpool.Connect(ctx, connString)
}
Works with Cassandra, DynamoDB, BigTable, MongoDB, Neo4j, other services.
### Shared databases
Default: per-service databases for isolation. Share via sqldb.Named:
// In report service, access todo service's database:
var todoDB = sqldb.Named("todo")
//encore:api method=GET path=/report/todo
func CountCompletedTodos(ctx context.Context) (*ReportResponse, error) {
var report ReportResponse
err := todoDB.QueryRow(ctx,`
SELECT COUNT(*) FROM todo_item WHERE completed = TRUE
`).Scan(&report.Total)
return &report, err
}
### Cron jobs
Declarative periodic tasks. Does not run locally or in Preview Environments.
import "encore.dev/cron"
var _ = cron.NewJob("welcome-email", cron.JobConfig{
Title: "Send welcome emails",
Every: 2 * cron.Hour,
Endpoint: SendWelcomeEmail,
})
//encore:api private
func SendWelcomeEmail(ctx context.Context) error {
return nil
}
Scheduling options:
- Every: Must divide 24 hours evenly (e.g., 10 * cron.Minute, 6 * cron.Hour)
- Schedule: Cron expressions (e.g., "0 4 15 * *" for 4am UTC on 15th)
Requirements: Endpoints must be idempotent, no request parameters, signature func(context.Context) error or func(context.Context) (*T, error)
### Caching
Redis-based distributed caching.
import "encore.dev/storage/cache"
var MyCacheCluster = cache.NewCluster("my-cache-cluster", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
// Keyspace with type safety
var RequestsPerUser = cache.NewIntKeyspace[auth.UID](cluster, cache.KeyspaceConfig{
KeyPattern: "requests/:key",
DefaultExpiry: cache.ExpireIn(10 * time.Second),
})
// Structured keys
type MyKey struct {
UserID auth.UID
ResourcePath string
}
var ResourceRequestsPerUser = cache.NewIntKeyspace[MyKey](cluster, cache.KeyspaceConfig{
KeyPattern: "requests/:UserID/:ResourcePath",
DefaultExpiry: cache.ExpireIn(10 * time.Second),
})
Supports strings, integers, floats, structs, sets, ordered lists.
### Object storage
Cloud-agnostic API compatible with S3, GCS, S3-compatible services.
var ProfilePictures = objects.NewBucket("profile-pictures", objects.BucketConfig{
Versioned: false,
})
// Public bucket with CDN
var PublicAssets = objects.NewBucket("public-assets", objects.BucketConfig{
Public: true,
})
Operations: Upload, Download, List, Remove, Attrs, Exists
Bucket references for permissions:
type myPerms interface {
objects.Downloader
objects.Uploader
}
ref := objects.BucketRef[myPerms](bucket)
### Pub/Sub
Async event broadcasting with automatic infra provisioning.
type SignupEvent struct{ UserID int }
var Signups = pubsub.NewTopic[*SignupEvent]("signups", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
// Publishing
messageID, err := Signups.Publish(ctx, &SignupEvent{UserID: id})
// Topic reference
signupRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](Signups)
// Subscribing
var _ = pubsub.NewSubscription(
user.Signups, "send-welcome-email",
pubsub.SubscriptionConfig[*SignupEvent]{
Handler: SendWelcomeEmail,
},
)
// Method handler with dependency injection
var _ = pubsub.NewSubscription(
user.Signups, "send-welcome-email",
pubsub.SubscriptionConfig[*SignupEvent]{
Handler: pubsub.MethodHandler((*Service).SendWelcomeEmail),
},
)
Delivery guarantees:
- AtLeastOnce: Handlers must be idempotent
- ExactlyOnce: Stronger guarantees (AWS: 300 msg/sec, GCP: 3000+ msg/sec)
Ordering: Use OrderingAttribute matching pubsub-attr tag
Testing:
msgs := et.Topic(Signups).PublishedMessages()
assert.Len(t, msgs, 1)
### Secrets
Built-in secrets manager for API keys, passwords, private keys.
var secrets struct {
SSHPrivateKey string
GitHubAPIToken string
}
func callGitHub(ctx context.Context) {
req.Header.Add("Authorization", "token " + secrets.GitHubAPIToken)
}
CLI management:
- encore secret set --type production secret-name
- encore secret set --type development secret-name
- encore secret set --env env-name secret-name (env-specific override)
Types: production (prod), development (dev), preview (pr), local
Local override via .secrets.local.cue:
GitHubAPIToken: "my-local-override-token"
### API calls
Call APIs like regular functions with automatic type checking:
import "encore.app/hello"
//encore:api public
func MyOtherAPI(ctx context.Context) error {
resp, err := hello.Ping(ctx, &hello.PingParams{Name: "World"})
if err == nil {
log.Println(resp.Message) // "Hello, World!"
}
return err
}
### Errors
Structured errors via encore.dev/beta/errs package.
return &errs.Error{
Code: errs.NotFound,
Message: "sprocket not found",
}
// Returns HTTP 404 {"code": "not_found", "message": "sprocket not found"}
Wrapping:
errs.Wrap(err, msg, metaPairs...)
errs.WrapCode(err, code, msg, metaPairs...)
Builder pattern:
eb := errs.B().Meta("board_id", params.ID)
return eb.Code(errs.NotFound).Msg("board not found").Err()
Error codes: OK(200), Canceled(499), Unknown(500), InvalidArgument(400), DeadlineExceeded(504), NotFound(404), AlreadyExists(409), PermissionDenied(403), ResourceExhausted(429), FailedPrecondition(400), Aborted(409), OutOfRange(400), Unimplemented(501), Internal(500), Unavailable(503), DataLoss(500), Unauthenticated(401)
Inspection: errs.Code(err), errs.Meta(err), errs.Details(err)
### Authentication
Flexible auth with different access levels.
import "encore.dev/beta/auth"
// Basic
//encore:authhandler
func AuthHandler(ctx context.Context, token string) (auth.UID, error) {
// Validate token and return user ID
}
// With user data
type Data struct {
Username string
}
//encore:authhandler
func AuthHandler(ctx context.Context, token string) (auth.UID, *Data, error) {
// Return user ID and custom data
}
// Structured auth params
type MyAuthParams struct {
SessionCookie *http.Cookie `cookie:"session"`
ClientID string `query:"client_id"`
Authorization string `header:"Authorization"`
}
//encore:authhandler
func AuthHandler(ctx context.Context, p *MyAuthParams) (auth.UID, error) {
// Process structured auth params
}
Usage: auth.Data(), auth.UserID()
Override for testing: auth.WithContext(ctx, auth.UID("my-user-id"), &MyAuthData{})
Error handling:
return "", &errs.Error{
Code: errs.Unauthenticated,
Message: "invalid token",
}
### Configuration
Env-specific config via CUE files.
package mysvc
import "encore.dev/config"
type SomeConfigType struct {
ReadOnly config.Bool
Example config.String
}
var cfg *SomeConfigType = config.Load[*SomeConfigType]()
CUE tags for constraints:
type FooBar {
A int `cue:">100"`
B int `cue:"A-50"`
C int `cue:"A+B"`
}
Config types: config.String, config.Bool, config.Int, config.Float64, config.Time, config.UUID, config.Value[T], config.Values[T]
Meta values:
- APIBaseURL, Environment.Name, Environment.Type (production/development/ephemeral/test), Environment.Cloud (aws/gcp/encore/local)
Testing: et.SetCfg(cfg.SendEmails, true)
CUE patterns:
- Defaults: value: type | *default_value
- Switch: array with conditionals, take [0]
### CORS
Configure in encore.app file:
- debug: Enable CORS debug logging
- allow_headers: Additional accepted headers ("*" allows all)
- expose_headers: Additional exposed headers
- allow_origins_without_credentials: Defaults to ["*"]
- allow_origins_with_credentials: For authenticated requests, supports wildcards like "https://*.example.com"
### Metadata
Access app + request info via encore.dev package.
// Application metadata
meta := encore.Meta()
// meta.AppID, meta.APIBaseURL, meta.Environment, meta.Build, meta.Deploy
// Request metadata
req := encore.CurrentRequest()
// req.Service, req.Endpoint, req.Path, req.StartTime
// Cloud-specific behavior
switch encore.Meta().Environment.Cloud {
case encore.CloudAWS:
return writeIntoRedshift(ctx, action, user)
case encore.CloudGCP:
return writeIntoBigQuery(ctx, action, user)
}
### Middleware
Reusable code running before/after API requests.
//encore:middleware global target=all
func ValidationMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
payload := req.Data().Payload
if validator, ok := payload.(interface { Validate() error }); ok {
if err := validator.Validate(); err != nil {
err = errs.WrapCode(err, errs.InvalidArgument, "validation failed")
return middleware.Response{Err: err}
}
}
return next(req)
}
// With dependency injection
//encore:middleware target=all
func (s *Service) MyMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
// Implementation
}
// Tag-based targeting
//encore:middleware target=tag:cache
func CachingMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
// ...
}
//encore:api public method=GET path=/user/:id tag:cache
func GetUser(ctx context.Context, id string) (*User, error) {
// Implementation
}
Ordering: Global before service-specific, lexicographic by filename.
### Mocking
Built-in mocking for isolated testing.
// Mock endpoint for single test
func Test_Something(t *testing.T) {
t.Parallel()
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
return &products.PriceResponse{Price: 100}, nil
})
}
// Mock endpoint for all tests in package
func TestMain(m *testing.M) {
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
return &products.PriceResponse{Price: 100}, nil
})
os.Exit(m.Run())
}
// Mock entire service
et.MockService("products", &products.Service{
SomeField: "a testing value",
})
// Type-safe service mocking
et.MockService[products.Interface]("products", &myMockObject{})
### Testing
Run tests: encore test ./...
Supports all standard go test flags. Built-in tracing at localhost:9400.
Database testing:
- Automatic setup in separate cluster, optimized for speed
- Temporary databases: et.NewTestDatabase() creates isolated, fully migrated DB
Service structs: Lazy init, instance sharing between tests
- Isolate: et.EnableServiceInstanceIsolation()
### Validation
Automatic request validation via Validate() method.
type MyRequest struct {
Email string
}
func (r *MyRequest) Validate() error {
if !isValidEmail(r.Email) {
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
}
return nil
}
Validation runs after deserialization, before handler. Non-errs.Error errors become InvalidArgument (HTTP 400).
### CGO
Enable in encore.app:
{
"id": "my-app-id",
"build": {
"cgo_enabled": true
}
}
Uses Ubuntu builder with gcc. Libraries must support static linking.
### Clerk auth
Implement Clerk auth:
package auth
import "github.com/clerkinc/clerk-sdk-go/clerk"
type Service struct {
client clerk.Client
}
func initService() (*Service, error) {
client, err := clerk.NewClient(secrets.ClientSecretKey)
if err != nil {
return nil, err
}
return &Service{client: client}, nil
}
type UserData struct {
ID string
Username *string
FirstName *string
LastName *string
ProfileImageURL string
PrimaryEmailAddressID *string
EmailAddresses []clerk.EmailAddress
}
//encore:authhandler
func (s *Service) AuthHandler(ctx context.Context, token string) (auth.UID, *UserData, error) {
// Token verification and user data retrieval
}
Set secrets:
- encore secret set --prod ClientSecretKey
- encore secret set --dev ClientSecretKey
### Dependency injection
Add dependencies as struct fields for easy testing:
package email
//encore:service
type Service struct {
sendgridClient *sendgrid.Client
}
func initService() (*Service, error) {
client, err := sendgrid.NewClient()
if err != nil {
return nil, err
}
return &Service{sendgridClient: client}, nil
}
//encore:api private
func (s *Service) Send(ctx context.Context, p *SendParams) error {
// Use s.sendgridClient
}
// For testing, use interface
type sendgridClient interface {
SendEmail(...)
}
func TestFoo(t *testing.T) {
svc := &Service{sendgridClient: &myMockClient{}}
// Test
}
### Pub/Sub outbox
Transactional outbox pattern for database + Pub/Sub consistency.
var SignupsTopic = pubsub.NewTopic[*SignupEvent](/* ... */)
ref := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
ref = outbox.Bind(ref, outbox.TxPersister(tx))
Required schema:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
data JSONB NOT NULL,
inserted_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX outbox_topic_idx ON outbox (topic, id);
Relay setup:
type Service struct {
signupsRef pubsub.Publisher[*SignupEvent]
}
func initService() (*Service, error) {
relay := outbox.NewRelay(outbox.SQLDBStore(db))
signupsRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
outbox.RegisterTopic(relay, signupsRef)
go relay.PollForMessage(context.Background(), -1)
return &Service{signupsRef: signupsRef}, nil
}
Supports: encore.dev/storage/sqldb, database/sql, github.com/jackc/pgx/v5
### Encore Toolbar (frontend dev panel)
Dev-only browser panel: intercepts frontend HTTP calls, captures Encore trace IDs, jumps to traces in the dashboard. Install: add `<script src="https://encore.dev/encore-toolbar.js"></script>` early in `<head>` (no `async`/`defer`). Reach for it when a frontend talks to an Encore Go backend.
### Example apps
- Hello World: https://github.com/encoredev/examples/tree/main/hello-world
- URL Shortener: https://github.com/encoredev/examples/tree/main/url-shortener
- Uptime Monitor: https://github.com/encoredev/examples/tree/main/uptime
## Encore CLI reference
Execution:
- encore help [command] - Lists available commands (or shows usage for specific command). Reach for it when unsure which subcommand or flag to use.
- encore run [--debug] [--watch=true] [-p port] [flags] - Runs app. Before starting yourself, check if already running by polling `http://localhost:<port>/__encore/healthz` (default port `4000`) — `200` = healthy, hit endpoints directly without re-running.
- encore check ['curl ...'; 'curl ...'] | encore check < commands.txt - Compile + boot app, verify every service healthy, optionally run curl commands. See dedicated section above.
- encore test ./... [go test flags] - Wraps `go test ./...` with Encore's infra provisioning — boots isolated test databases, Pub/Sub, etc. before handing off to `go test`. Accepts all `go test` flags, plus `--prepare`, `--codegen-debug`, `--trace`.
- encore exec path/to/script [args...] - Run an executable script against the local Encore app
App management:
- encore app clone [app-id] [directory] - Clone app
- encore app create [name] [--example=name] [-l go] - Create new app
- encore app init [name] - Create from existing repo
- encore app link [app-id] [-f] - Link app with server
Authentication:
- encore auth login/logout/signup/whoami
Daemon:
- encore daemon - Restart daemon
- encore daemon env - Output environment info
Database:
- encore db shell database-name [--env=name] - psql shell (--write, --admin, --superuser)
- encore db conn-uri database-name [--env=name] - Connection string
- encore db proxy [--env=name] - Local proxy
- encore db reset <db-names...|--all> - Reset databases
Code generation:
- encore gen client [app-id] [--env=name] [--lang=lang] - Generate API client
Languages: go, typescript, javascript, openapi
Logging:
- encore logs [--env=prod] [--json] [-q] - Stream logs
Kubernetes:
- encore k8s configure --env=ENV_NAME - Update kubectl config
Secrets:
- encore secret set --type TYPE <secret-name> (types: production, development, preview, local)
- encore secret set --env env-name <secret-name>
- encore secret list [keys...]
- encore secret delete <id>
Namespaces (infrastructure environments, alias `encore ns`):
- encore namespace list [--output=columns|json] - List infra namespaces
- encore namespace create NAME - Create a namespace
- encore namespace switch [--create] NAME - Switch active namespace
- encore namespace delete NAME - Delete a namespace
MCP (programmatic access to the local app — same surface as `.mcp.json`):
- encore mcp run - stdio-based MCP session
- encore mcp start - SSE-based MCP session; prints the SSE URL
Config:
- encore config <key> [<value>] [--app|--global|--all] - Get/set CLI configuration
Telemetry:
- encore telemetry - Status
- encore telemetry enable / disable - Toggle telemetry reporting
LLM rules:
- encore llm-rules init - Generate LLM rule files for this project
Random data (utility):
- encore rand uuid [-1|-4|-6|-7] - Generate UUID (default v4)
- encore rand bytes N [-f format] - Generate N random bytes
- encore rand words [--sep=SEP] NUM - Generate memorable passphrase
Deploy (alpha):
- encore alpha deploy --env=<name> (--commit=<sha> | --branch=<name>) - Deploy app to a cloud env
Version:
- encore version - Report version
- encore version update - Check and apply updates
Build:
- encore build docker IMAGE_TAG [--base string] [--push] [--cgo] [--os] [--arch] - Build Docker image

140
.gitignore vendored
View File

@@ -1,31 +1,29 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
# Encore
/.encore
encore.gen.go
encore.gen.cue
/encore.gen
.secrets.local.cue
.secrets.local.cue
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
# Environment files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# ---> Node
# Logs
logs
*.log
@@ -35,7 +33,7 @@ yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
@@ -44,122 +42,52 @@ pids
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
# Coverage and build output
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
dist
out
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
bower_components/
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
# Tool caches
lib-cov
.grunt
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.parcel-cache
.next
.nuxt
.temp
.cache
# vitepress build output
.vuepress/dist
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
# TypeScript
*.tsbuildinfo
# Packaging
*.tgz
# Yarn
.yarn-integrity
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"encore-local": {
"args": [
"mcp",
"run",
"--app=q55oi"
],
"command": "encore"
}
}
}

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Connect to server",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "${workspaceFolder}",
"port": 2345,
"host": "127.0.0.1"
}
]
}

View File

@@ -1,3 +1,50 @@
# encore-test
test del framework encore
Test del framework Encore con backend Go e frontend Quasar.
## Prerequisiti
- Encore: `brew install encoredev/tap/encore`
- Go
- Node.js e pnpm per il frontend
## Avvio locale
Backend:
```bash
encore run
```
Frontend:
```bash
cd frontend
pnpm install
pnpm dev
```
## Test
Backend:
```bash
go test ./...
```
Frontend:
```bash
cd frontend
pnpm typecheck
```
## Note Encore
Con `encore run` attivo, il dashboard locale e' disponibile su:
```text
http://localhost:9400/
```
Le API locali sono esposte dall'ambiente Encore locale. Consulta il Service Catalog nel dashboard per endpoint e schema aggiornati.

339
admin/admin.go Normal file
View File

@@ -0,0 +1,339 @@
// Service admin provides administrative endpoints for managing application data.
package admin
import (
"context"
"errors"
"time"
authsvc "encore.app/auth"
profilessvc "encore.app/profiles"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
var profilesDB = sqldb.Named("profiles")
type Profile struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
IsArtist bool `json:"is_artist"`
Role string `json:"role"`
Status profilessvc.Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProfileParams struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
type RegisterParams struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Password string `json:"password" encore:"sensitive"`
}
type ListProfilesResponse struct {
Profiles []*Profile `json:"profiles"`
}
// ListProfiles returns all user profiles, ordered by user ID.
//
//encore:api auth method=GET path=/admin/profiles
func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) {
rows, err := profilesDB.Query(ctx, `
SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at
FROM user_profiles
ORDER BY user_id
`)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to list profiles")
}
defer rows.Close()
profiles := []*Profile{}
for rows.Next() {
var p Profile
if err := rows.Scan(&p.UserID, &p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to scan profile")
}
profiles = append(profiles, &p)
}
if err := rows.Err(); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to list profiles")
}
return &ListProfilesResponse{Profiles: profiles}, nil
}
// GetProfile returns the profile for the given user.
//
//encore:api auth method=GET path=/admin/profiles/:userID
func GetProfile(ctx context.Context, userID uuid.UUID) (*Profile, error) {
p := Profile{UserID: userID}
err := profilesDB.QueryRow(ctx, `
SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1
`, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile")
}
return &p, nil
}
// InsertProfile creates a new user profile with a server-generated UUID v4,
// delegating credential setup to the auth service.
//
//encore:api auth method=POST path=/admin/profiles
func InsertProfile(ctx context.Context, p *RegisterParams) (*Profile, error) {
userID, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
}
profile := Profile{UserID: userID, Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
err = profilesDB.QueryRow(ctx, `
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
VALUES ($1, $2, $3, $4)
RETURNING role, status, is_artist, created_at, updated_at
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if isUniqueViolation(err) {
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create profile")
}
if err := authsvc.Register(ctx, &authsvc.RegisterParams{UserID: userID, Password: p.Password}); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to register credentials")
}
return &profile, nil
}
// UpdateProfile replaces the data of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID
func UpdateProfile(ctx context.Context, userID uuid.UUID, p *ProfileParams) (*Profile, error) {
profile := Profile{UserID: userID, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
err := profilesDB.QueryRow(ctx, `
UPDATE user_profiles
SET display_name = $2, avatar_url = $3, updated_at = NOW()
WHERE user_id = $1
RETURNING email, role, status, is_artist, created_at, updated_at
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile")
}
return &profile, nil
}
type UpdateProfileStatusParams struct {
Status profilessvc.Status `json:"status"`
}
func (p *UpdateProfileStatusParams) Validate() error {
if !p.Status.IsValid() {
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid status"}
}
return nil
}
// UpdateProfileStatus sets the status of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/status
func UpdateProfileStatus(ctx context.Context, userID uuid.UUID, p *UpdateProfileStatusParams) (*Profile, error) {
profile := Profile{UserID: userID, Status: p.Status}
err := profilesDB.QueryRow(ctx, `
UPDATE user_profiles
SET status = $2, updated_at = NOW()
WHERE user_id = $1
RETURNING email, display_name, avatar_url, role, is_artist, created_at, updated_at
`, userID, p.Status).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile status")
}
return &profile, nil
}
type UpdateProfileRoleParams struct {
Role string `json:"role"`
}
func (p *UpdateProfileRoleParams) Validate() error {
if !authsvc.IsValidRole(p.Role) {
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid role"}
}
return nil
}
// UpdateProfileRole sets the role of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/role
func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRoleParams) (*Profile, error) {
profile := Profile{UserID: userID, Role: p.Role}
err := profilesDB.QueryRow(ctx, `
UPDATE user_profiles
SET role = $2, updated_at = NOW()
WHERE user_id = $1
RETURNING email, display_name, avatar_url, status, is_artist, created_at, updated_at
`, userID, p.Role).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile role")
}
return &profile, nil
}
type UpdateProfilePasswordParams struct {
Password string `json:"password" encore:"sensitive"`
}
// UpdateProfilePassword sets the password of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/password
func UpdateProfilePassword(ctx context.Context, userID uuid.UUID, p *UpdateProfilePasswordParams) error {
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
return err
}
return nil
}
type UpdateProfileArtistParams struct {
IsArtist bool `json:"is_artist"`
}
// UpdateProfileArtist sets the artist flag of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/artist
func UpdateProfileArtist(ctx context.Context, userID uuid.UUID, p *UpdateProfileArtistParams) (*Profile, error) {
profile := Profile{UserID: userID, IsArtist: p.IsArtist}
err := profilesDB.QueryRow(ctx, `
UPDATE user_profiles
SET is_artist = $2, updated_at = NOW()
WHERE user_id = $1
RETURNING email, display_name, avatar_url, role, status, created_at, updated_at
`, userID, p.IsArtist).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.Status, &profile.CreatedAt, &profile.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile artist flag")
}
return &profile, nil
}
// DeleteProfile removes any user's profile.
//
//encore:api auth method=DELETE path=/admin/profiles/:userID
func DeleteProfile(ctx context.Context, userID uuid.UUID) error {
res, err := profilesDB.Exec(ctx, `
DELETE FROM user_profiles WHERE user_id = $1
`, userID)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to delete profile")
}
if res.RowsAffected() == 0 {
return &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
return nil
}
type PersonalData struct {
UserID uuid.UUID `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PersonalDataParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
}
// GetPersonalData returns the personal data for the given user.
//
//encore:api auth method=GET path=/admin/profiles/:userID/personal-data
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
pd := PersonalData{UserID: userID}
err := profilesDB.QueryRow(ctx, `
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
FROM personal_data WHERE user_id = $1
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal data")
}
return &pd, nil
}
// UpsertPersonalData creates or replaces the personal data for the given user.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/personal-data
func UpsertPersonalData(ctx context.Context, userID uuid.UUID, p *PersonalDataParams) (*PersonalData, error) {
pd := PersonalData{
UserID: userID,
FirstName: p.FirstName,
LastName: p.LastName,
Address: p.Address,
Cap: p.Cap,
City: p.City,
Country: p.Country,
}
err := profilesDB.QueryRow(ctx, `
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE
SET first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
address = EXCLUDED.address,
cap = EXCLUDED.cap,
city = EXCLUDED.city,
country = EXCLUDED.country,
updated_at = NOW()
RETURNING created_at, updated_at
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
if isForeignKeyViolation(err) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal data")
}
return &pd, nil
}
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// isForeignKeyViolation reports whether err is a Postgres foreign key constraint violation.
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23503"
}

23
admin/middleware.go Normal file
View File

@@ -0,0 +1,23 @@
package admin
import (
authsvc "encore.app/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/middleware"
)
// RequireAdmin ensures the caller is logged in and has the admin role
// before any endpoint in this service runs.
//
//encore:middleware target=all
func RequireAdmin(req middleware.Request, next middleware.Next) middleware.Response {
if _, ok := auth.UserID(); !ok {
return middleware.Response{Err: &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}}
}
data, _ := auth.Data().(*authsvc.AuthData)
if data == nil || !data.Role.IsAdmin() {
return middleware.Response{Err: &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"}}
}
return next(req)
}

24
admin/system.go Normal file
View File

@@ -0,0 +1,24 @@
package admin
import (
"context"
authsvc "encore.app/auth"
profilessvc "encore.app/profiles"
)
type SystemOptionsResponse struct {
Roles []authsvc.RoleOption `json:"roles"`
Statuses []profilessvc.StatusOption `json:"statuses"`
}
// GetSystemOptions returns the valid roles and profile statuses, each with a
// display name and underlying value, for use in admin UI dropdowns.
//
//encore:api auth method=GET path=/admin/system/options
func GetSystemOptions(ctx context.Context) (*SystemOptionsResponse, error) {
return &SystemOptionsResponse{
Roles: authsvc.Roles(),
Statuses: profilessvc.Statuses(),
}, nil
}

12
assicurazione.md Normal file
View File

@@ -0,0 +1,12 @@
N. sinistro 2026 7196966
1. descrizione dell'incidente
procedevo salendo verso Breno sulla strada cantonale (classificata strada di montagna). Affrontando la curva al tornante che prima di arrivare al negozio "Bottega Breno". La careggiata in questo tornante non ofre visibilità è necessario osservare la massima prudenza, cosa che percorrendo regolarmente questa strada applico regolarmente. Ho affrontato la curva come sempre con prudenza e tenendo la destra in modo adeguato. Essendo la careggiata non larga a sufficienza per incrociare due veicoli senza che uno si debba fermare. Inoltre visto la dimensione ridotta non c'è la segalazione di careggiata. (linea bianca al centro). Il veicolo che procedeva in discesa non mi ha concesso la precedenza, non si è fermato e mi ha urtato la parte frontale sinistra.
allego alla risposta alcune fotografie indicative e lo schizzo.
2. secondo lei, a chi è attribuibile la colpa e perche?
a mio avviso la colpa è attribuibile al vostro assicurato. Avrebbe dovuto prestare prudenza e rispettare la precedenza.
Manno 3.07.2026

110
auth/auth.go Normal file
View File

@@ -0,0 +1,110 @@
// Service auth owns authentication: credentials, sessions, login and logout.
package auth
import (
"context"
"crypto/sha256"
"errors"
"net/http"
"strings"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
)
var db = sqldb.NewDatabase("auth", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
var profilesDB = sqldb.Named("profiles")
// PasswordPepper is mixed into every password before hashing, on top of
// bcrypt's per-password salt, so leaked password hashes are useless without it.
var secrets struct {
PasswordPepper string
}
const sessionTTL = 7 * 24 * time.Hour
type AuthParams struct {
SessionCookie *http.Cookie `cookie:"session"`
Authorization string `header:"Authorization"`
}
// AuthData is exposed to authenticated handlers via auth.Data().
type AuthData struct {
SessionToken uuid.UUID
Role role
}
// AuthHandler authenticates a request by looking up its session token — taken
// from the session cookie, or as a fallback the "Bearer <token>" Authorization
// header — against active sessions.
//
//encore:authhandler
func AuthHandler(ctx context.Context, p *AuthParams) (auth.UID, *AuthData, error) {
token, ok := sessionToken(p)
if !ok {
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "missing session credentials"}
}
var userID uuid.UUID
err := db.QueryRow(ctx, `
SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID)
if errors.Is(err, sqldb.ErrNoRows) {
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid or expired session"}
}
if err != nil {
return "", nil, errs.WrapCode(err, errs.Internal, "failed to validate session")
}
var roleStr string
err = profilesDB.QueryRow(ctx, `
SELECT role FROM user_profiles WHERE user_id = $1
`, userID).Scan(&roleStr)
if err != nil && !errors.Is(err, sqldb.ErrNoRows) {
return "", nil, errs.WrapCode(err, errs.Internal, "failed to load user role")
}
r := role(roleStr)
if !r.IsValid() {
r = roleUser
}
return auth.UID(userID.String()), &AuthData{SessionToken: token, Role: r}, nil
}
func sessionToken(p *AuthParams) (uuid.UUID, bool) {
if p.SessionCookie != nil {
if id, err := uuid.FromString(p.SessionCookie.Value); err == nil {
return id, true
}
}
if rest, ok := strings.CutPrefix(p.Authorization, "Bearer "); ok {
if id, err := uuid.FromString(rest); err == nil {
return id, true
}
}
return uuid.Nil, false
}
// pepperedPassword pre-hashes the password together with the application-wide
// pepper secret using SHA-256, producing a fixed-size digest. This both mixes
// in the pepper and keeps the input to bcrypt within its 72-byte limit
// regardless of the original password's length.
func pepperedPassword(password string) []byte {
sum := sha256.Sum256([]byte(password + secrets.PasswordPepper))
return sum[:]
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(pepperedPassword(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

111
auth/credentials.go Normal file
View File

@@ -0,0 +1,111 @@
package auth
import (
"context"
"errors"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
)
type RegisterParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
// Register stores the initial password hash for a newly created profile.
// Called directly (service-to-service) by profiles.Insert during registration,
// since that flow needs to know immediately whether credential setup succeeded.
//
//encore:api private method=POST path=/auth/credentials
func Register(ctx context.Context, p *RegisterParams) error {
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")
}
_, err = db.Exec(ctx, `
INSERT INTO credentials (user_id, password_hash) VALUES ($1, $2)
`, p.UserID, hash)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to store credentials")
}
return nil
}
type SetPasswordParams struct {
CurrentPassword string `json:"current_password" encore:"sensitive"`
Password string `json:"password" encore:"sensitive"`
}
type SetUserPasswordParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
// SetPassword changes the password for the authenticated user.
//
//encore:api auth method=PUT path=/auth/password
func SetPassword(ctx context.Context, p *SetPasswordParams) error {
uid, ok := auth.UserID()
if !ok {
return &errs.Error{Code: errs.Unauthenticated, Message: "missing auth"}
}
userID, err := uuid.FromString(string(uid))
if err != nil {
return errs.WrapCode(err, errs.Internal, "invalid user id")
}
var currentHash string
err = db.QueryRow(ctx, `
SELECT password_hash FROM credentials WHERE user_id = $1
`, userID).Scan(&currentHash)
if errors.Is(err, sqldb.ErrNoRows) {
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
}
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), pepperedPassword(p.CurrentPassword)); err != nil {
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
}
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")
}
_, err = db.Exec(ctx, `
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
`, userID, hash)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to update password")
}
return nil
}
// SetUserPassword changes a user's password from trusted internal flows.
//
//encore:api private method=PUT path=/auth/internal/users/:userID/password
func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordParams) error {
if p.UserID != uuid.Nil && p.UserID != userID {
return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"}
}
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")
}
res, err := db.Exec(ctx, `
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
`, userID, hash)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to update password")
}
if res.RowsAffected() == 0 {
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
}
return nil
}

119
auth/login.go Normal file
View File

@@ -0,0 +1,119 @@
package auth
import (
"context"
"errors"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
)
type LoginParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
type LoginResponse struct {
Token uuid.UUID `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
// Login verifies a user's password and issues a new session token.
//
// It is private — external clients authenticate via the public
// profiles.Login, which talks to this service asynchronously over Pub/Sub
// (see LoginRequests/LoginResults below) rather than calling it directly.
//
//encore:api private method=POST path=/auth/internal/login
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
var hash string
err := db.QueryRow(ctx, `
SELECT password_hash FROM credentials WHERE user_id = $1
`, p.UserID).Scan(&hash)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), pepperedPassword(p.Password)); err != nil {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
}
token, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate session token")
}
expiresAt := time.Now().Add(sessionTTL)
_, err = db.Exec(ctx, `
INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)
`, token, p.UserID, expiresAt)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create session")
}
return &LoginResponse{Token: token, ExpiresAt: expiresAt}, nil
}
// Logout revokes the session used to authenticate the current request.
//
//encore:api auth method=POST path=/auth/logout
func Logout(ctx context.Context) error {
data, _ := auth.Data().(*AuthData)
if data == nil {
return nil
}
_, err := db.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, data.SessionToken)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to revoke session")
}
return nil
}
// LoginRequested is published by profiles.Login to ask this service to
// authenticate a user asynchronously — the public endpoint lives in profiles,
// but the credential check and session issuance happen here.
type LoginRequested struct {
RequestID uuid.UUID
UserID uuid.UUID
Password string `encore:"sensitive"`
}
var LoginRequests = pubsub.NewTopic[*LoginRequested]("login-requests", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
// LoginCompleted carries the outcome of a LoginRequested message back to
// whichever profiles instance is waiting on the matching RequestID.
type LoginCompleted struct {
RequestID uuid.UUID
OK bool
Token uuid.UUID
ExpiresAt time.Time
}
var LoginResults = pubsub.NewTopic[*LoginCompleted]("login-results", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(
LoginRequests, "authenticate",
pubsub.SubscriptionConfig[*LoginRequested]{
Handler: handleLoginRequested,
},
)
func handleLoginRequested(ctx context.Context, ev *LoginRequested) error {
result := &LoginCompleted{RequestID: ev.RequestID}
if resp, err := Login(ctx, &LoginParams{UserID: ev.UserID, Password: ev.Password}); err == nil {
result.OK = true
result.Token = resp.Token
result.ExpiresAt = resp.ExpiresAt
}
_, err := LoginResults.Publish(ctx, result)
return err
}

View File

@@ -0,0 +1,15 @@
CREATE TABLE credentials (
user_id UUID PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE sessions (
token UUID PRIMARY KEY,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);

43
auth/roles.go Normal file
View File

@@ -0,0 +1,43 @@
package auth
type role string
const (
roleUser role = "user"
roleAdmin role = "admin"
)
func (r role) String() string {
return string(r)
}
func (r role) IsValid() bool {
return r == roleUser || r == roleAdmin
}
func (r role) IsAdmin() bool {
return r == roleAdmin
}
func (r role) IsUser() bool {
return r == roleUser
}
// RoleOption describes a valid role for display in admin UIs.
type RoleOption struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Roles returns every valid role with a display name and its underlying value.
func Roles() []RoleOption {
return []RoleOption{
{Name: "User", Value: string(roleUser)},
{Name: "Admin", Value: string(roleAdmin)},
}
}
// IsValidRole reports whether value is a valid role.
func IsValidRole(value string) bool {
return role(value).IsValid()
}

5
encore.app Normal file
View File

@@ -0,0 +1,5 @@
{
// The app is not currently linked to the encore.dev platform.
// Use "encore app link" to link it.
"id": "",
}

7
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

26
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
.DS_Store
.thumbs.db
node_modules
# .env files
.env*
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*

13
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"recommendations": [
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

10
frontend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"js/ts.tsdk.path": "node_modules/typescript/lib",
"search.exclude": {
"dist/": true,
".quasar/": true,
"/quasar.config.js.temporary.*": true
}
}

23
frontend/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Quasar App (frontend)
## Install the dependencies
```bash
pnpm install
# or: yarn/npm/bun install
```
### Start the app in development mode (HMR, error reporting, etc.)
```bash
quasar dev
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

15
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* Add types (that are not auto-magically added by Quasar CLI already)
* for your custom variables to avoid TypeScript errors, like dynamic
* process.env variables or definitions in dotenv files configured ONLY
* for the /quasar.config file itself.
*
* https://quasar.dev/quasar-cli-vite/handling-import-meta-env#type-inference
*
* @example
* interface ImportMetaEnv {
* readonly MY_VAR: string;
* readonly MY_OTHER_VAR: string;
* }
*/
interface ImportMetaEnv {}

25
frontend/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:* http://127.0.0.1:*<% } %>; connect-src 'self' blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:4000 ws://localhost:*<% } %>;<% if (ctx.dev) { %> worker-src 'self' blob:;<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "frontend",
"version": "0.0.1",
"description": "A Quasar Project",
"productName": "Quasar App",
"author": "fabio <prada.fabio@gmail.com>",
"type": "module",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"typecheck": "vue-tsc --noEmit",
"zod:sync": "node tools/zod-sync.mjs",
"postinstall": "quasar prepare --silent"
},
"dependencies": {
"@quasar/extras": "^2.0.0",
"pinia": "^3.0.4",
"quasar": "^2.20.0",
"vue": "^3.5.22",
"vue-advanced-cropper": "^2.8.9",
"vue-i18n": "^11.4.6",
"vue-router": "^5.0.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@quasar/app-vite": "^3.0.0-rc.2",
"@types/node": "^22.19.11",
"autoprefixer": "^10.4.27",
"typescript": "^6.0.0",
"vue-tsc": "^3.3.3"
},
"keywords": [
"quasar",
"quasar-app",
"quasar-cli",
"quasar-app-vite",
"vite",
"vue",
"vuejs"
],
"engines": {
"node": ">= 26 || ^24 || ^22.12"
}
}

3035
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
# https://pnpm.io/settings
allowBuilds:
'@parcel/watcher': true
core-js: true
electron-winstaller: true
esbuild: true
lightningcss: true
rolldown: true
unrs-resolver: true

View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer'
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
]
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

218
frontend/quasar.config.ts Normal file
View File

@@ -0,0 +1,218 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app';
export default defineConfig((/* ctx */) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [
'i18n',
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: [
'app.css'
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v7',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
target: {
// browser: 'baseline-widely-available',
// node: 'node22'
},
typescript: {
strict: true,
vueShim: true
// extendTsConfig (tsConfig) {}
},
// https://v2.quasar.dev/quasar-cli-vite/page-routing-with-vue-router#filename-based-routing
// filenameBasedRouting: true,
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// publicPath: '/',
// define: {},
// defineEnv: {}
// ignorePublicFolder: true,
// minify: false,
// distDir
extendViteConf (viteConf) {
viteConf.optimizeDeps = {
...viteConf.optimizeDeps,
include: [
...(viteConf.optimizeDeps?.include ?? []),
'zod',
'vue-i18n',
],
};
},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
// ]
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: ['Notify']
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
store: 'src/stores/index',
// pwaRegisterServiceWorker: 'src-pwa/register-sw',
// pwaServiceWorker: 'src-pwa/sw/custom-sw',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json'
},
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render' // keep this as last one
],
// extendSSRPackageJson (pkgJson) {},
// extendSSRWebserverConf (rolldownConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// extendSSRGenerateSWOptions (cfg) {},
// extendSSRInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendPWAManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPWAMetaTags: false,
// extendPWACustomSWConf (rolldownConf) {},
// extendPWAGenerateSWOptions (cfg) {},
// extendPWAInjectManifestOptions (cfg) {},
// extendPWASwTsConfig (tsConfig) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {},
// https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true
},
// https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (rolldownConf) {},
// extendElectronPreloadConf (rolldownConf) {},
// extendElectronPackageJson (pkgJson) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: [ 'electron-preload' ],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration
appId: 'frontend'
}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (rolldownConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: []
}
}
});

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

View File

@@ -0,0 +1,6 @@
import type { App } from 'vue';
import { i18n } from '@/i18n';
export default ({ app }: { app: App<Element> }) => {
app.use(i18n);
};

View File

@@ -0,0 +1,240 @@
<template>
<div class="row items-center q-gutter-md">
<q-avatar size="64px">
<img v-if="modelValue" :src="modelValue" />
<q-icon v-else name="person" size="40px" />
</q-avatar>
<div class="row items-center q-gutter-xs">
<q-btn round flat color="primary" icon="upload" :aria-label="t('actions.chooseFile')" @click="pickFile">
<q-tooltip>{{ t('actions.chooseFile') }}</q-tooltip>
</q-btn>
<q-btn round flat color="primary" icon="photo_camera" :aria-label="t('actions.camera')" @click="openCamera">
<q-tooltip>{{ t('actions.camera') }}</q-tooltip>
</q-btn>
<q-btn
v-if="modelValue"
round
flat
color="warning"
icon="delete"
:aria-label="t('actions.remove')"
@click="$emit('update:modelValue', '')"
>
<q-tooltip>{{ t('actions.remove') }}</q-tooltip>
</q-btn>
</div>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileSelected" />
<q-dialog v-model="cameraOpen" @hide="stopCamera">
<q-card style="min-width: 350px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">{{ t('actions.camera') }}</div>
</q-card-section>
<q-card-section>
<video
ref="videoRef"
class="camera-preview"
autoplay
muted
playsinline
/>
<div v-if="cameraError" class="text-negative text-caption q-mt-sm">
{{ cameraError }}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.takePhoto')" :disable="!cameraReady" @click="capturePhoto" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="cropperOpen" @show="ready = true" @hide="onHide">
<q-card style="min-width: 350px; max-width: 90vw; position: relative;">
<q-card-section>
<div class="text-h6">{{ t('avatar.cropAvatar') }}</div>
</q-card-section>
<q-card-section>
<CropperImage
v-if="source && ready"
ref="cropperRef"
class="cropper"
:src="source"
:width="220"
:height="220"
:show-controls="false"
:show-preview="false"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="uploading" @click="cropAndUpload" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<style scoped>
.cropper {
width: min(420px, 82vw);
padding: 0;
border: 0;
background: #DDD;
}
.camera-preview {
display: block;
width: min(420px, 82vw);
max-height: 420px;
background: #222;
}
</style>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import CropperImage from '@/components/CropperImage.vue';
const props = defineProps<{
modelValue: string;
/** Uploads the cropped image and resolves to its public URL. */
uploader: (image: Blob) => Promise<string>;
}>();
const { t } = useI18n();
const emit = defineEmits<{
'update:modelValue': [url: string];
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const cropperRef = ref<InstanceType<typeof CropperImage> | null>(null);
const videoRef = ref<HTMLVideoElement | null>(null);
const cropperOpen = ref(false);
const cameraOpen = ref(false);
const ready = ref(false);
const uploading = ref(false);
const cameraReady = ref(false);
const cameraError = ref<string | null>(null);
const source = ref<string | null>(null);
let cameraStream: MediaStream | null = null;
function pickFile() {
fileInput.value?.click();
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setCropperSource(file);
// Allow selecting the same file again later.
input.value = '';
}
function onHide() {
ready.value = false;
clearCropperSource();
}
async function openCamera() {
cameraError.value = null;
cameraReady.value = false;
cameraOpen.value = true;
await nextTick();
await startCamera();
}
async function startCamera() {
if (!navigator.mediaDevices?.getUserMedia) {
cameraError.value = t('avatar.cameraUnavailable');
return;
}
try {
stopCamera();
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
});
if (!videoRef.value) return;
videoRef.value.srcObject = cameraStream;
await videoRef.value.play();
cameraReady.value = true;
} catch (err) {
cameraError.value = err instanceof Error ? err.message : String(err);
stopCamera();
}
}
function stopCamera() {
cameraStream?.getTracks().forEach((track) => track.stop());
cameraStream = null;
cameraReady.value = false;
if (videoRef.value) {
videoRef.value.srcObject = null;
}
}
async function capturePhoto() {
const video = videoRef.value;
if (!video?.videoWidth || !video.videoHeight) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
if (!context) return;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png');
});
if (!blob) return;
cameraOpen.value = false;
stopCamera();
setCropperSource(blob);
}
function setCropperSource(file: Blob) {
clearCropperSource();
source.value = URL.createObjectURL(file);
cropperOpen.value = true;
}
function clearCropperSource() {
if (!source.value) return;
URL.revokeObjectURL(source.value);
source.value = null;
}
async function cropAndUpload() {
const blob = await cropperRef.value?.getCroppedBlob('image/png');
if (!blob) return;
uploading.value = true;
try {
const url = await props.uploader(blob);
emit('update:modelValue', url);
cropperOpen.value = false;
} catch {
// Errors surface through the store's shared error state.
} finally {
uploading.value = false;
}
}
onBeforeUnmount(() => {
stopCamera();
clearCropperSource();
});
</script>

View File

@@ -0,0 +1,480 @@
<template>
<div class="cropper-image">
<div
ref="stageRef"
class="cropper-stage"
@wheel.prevent="zoomImage"
@pointerdown="startImageDrag"
@pointermove="dragImage"
@pointerup="stopImageDrag"
@pointercancel="stopImageDrag"
>
<img
ref="imageRef"
class="cropper-source"
:style="imageStyle"
:src="imageSrc"
alt="Crop source"
draggable="false"
@load="updateImageLayout"
/>
<div
class="cropper-mask"
:style="selectionStyle"
@pointerdown="startDrag"
@pointermove="dragSelection"
@pointerup="stopDrag"
@pointercancel="stopDrag"
/>
</div>
<div v-if="showControls" class="cropper-controls">
<button type="button" @click="cropImage">{{ t('actions.crop') }}</button>
</div>
<canvas
ref="canvasRef"
class="cropper-preview"
:class="{ 'cropper-preview--hidden': !showPreview }"
:width="width"
:height="height"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import cropperImageUrl from '@/assets/pexels-photo-4323307.jpg';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
src?: string;
width?: number;
height?: number;
showControls?: boolean;
showPreview?: boolean;
}>(),
{
src: cropperImageUrl,
width: 45,
height: 45,
showControls: true,
showPreview: true,
},
);
const imageSrc = computed(() => props.src);
const stageRef = ref<HTMLDivElement | null>(null);
const imageRef = ref<HTMLImageElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
let resizeObserver: ResizeObserver | null = null;
let baseImageScale = 1;
const imageLayout = reactive({
left: 0,
top: 0,
width: 0,
height: 0,
zoom: 1,
});
const selection = reactive({
x: 20,
y: 15,
width: 45,
height: 45,
});
const dragState = reactive({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
selectionX: 0,
selectionY: 0,
});
const imageDragState = reactive({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
imageLeft: 0,
imageTop: 0,
});
const maxX = computed(() => 100 - selection.width);
const maxY = computed(() => 100 - selection.height);
const imageStyle = computed(() => ({
left: `${imageLayout.left}px`,
top: `${imageLayout.top}px`,
width: `${imageLayout.width}px`,
height: `${imageLayout.height}px`,
}));
const selectionStyle = computed(() => ({
left: `${selection.x}%`,
top: `${selection.y}%`,
width: `${selection.width}%`,
height: `${selection.height}%`,
}));
watch(
() => [selection.x, selection.y, selection.width, selection.height],
() => {
selection.x = Math.min(selection.x, maxX.value);
selection.y = Math.min(selection.y, maxY.value);
cropImage();
},
);
watch(
() => [props.width, props.height],
() => {
updateSelectionSizeFromProps();
},
);
watch(
() => props.src,
async () => {
imageLayout.zoom = 1;
imageLayout.width = 0;
imageLayout.height = 0;
await nextTick();
updateImageLayout();
},
);
onMounted(async () => {
await nextTick();
updateImageLayout();
if (stageRef.value) {
resizeObserver = new ResizeObserver(() => {
updateImageLayout();
});
resizeObserver.observe(stageRef.value);
}
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
function cropImage() {
const image = imageRef.value;
const canvas = canvasRef.value;
const stage = stageRef.value;
if (
!image ||
!canvas ||
!stage ||
!image.naturalWidth ||
!image.naturalHeight ||
!imageLayout.width ||
!imageLayout.height
) {
return;
}
const stageRect = stage.getBoundingClientRect();
const maskLeft = (selection.x / 100) * stageRect.width;
const maskTop = (selection.y / 100) * stageRect.height;
const maskWidth = (selection.width / 100) * stageRect.width;
const maskHeight = (selection.height / 100) * stageRect.height;
const sx = ((maskLeft - imageLayout.left) / imageLayout.width) * image.naturalWidth;
const sy = ((maskTop - imageLayout.top) / imageLayout.height) * image.naturalHeight;
const sw = (maskWidth / imageLayout.width) * image.naturalWidth;
const sh = (maskHeight / imageLayout.height) * image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}
async function getCroppedBlob(type = 'image/png', quality?: number): Promise<Blob | null> {
cropImage();
const canvas = canvasRef.value;
if (!canvas) return null;
return await new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), type, quality);
});
}
defineExpose({
cropImage,
getCroppedBlob,
});
function updateImageLayout() {
const image = imageRef.value;
const stage = stageRef.value;
if (!image || !stage || !image.naturalWidth || !image.naturalHeight) return;
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const hadLayout = imageLayout.width > 0 && imageLayout.height > 0;
const visibleCenterX = hadLayout ? rect.width / 2 - imageLayout.left : 0;
const visibleCenterY = hadLayout ? rect.height / 2 - imageLayout.top : 0;
const centerRatioX = hadLayout ? visibleCenterX / imageLayout.width : 0.5;
const centerRatioY = hadLayout ? visibleCenterY / imageLayout.height : 0.5;
baseImageScale = Math.min(1, rect.width / image.naturalWidth, rect.height / image.naturalHeight);
const scale = baseImageScale * imageLayout.zoom;
imageLayout.width = image.naturalWidth * scale;
imageLayout.height = image.naturalHeight * scale;
imageLayout.left = hadLayout
? rect.width / 2 - imageLayout.width * centerRatioX
: (rect.width - imageLayout.width) / 2;
imageLayout.top = hadLayout
? rect.height / 2 - imageLayout.height * centerRatioY
: (rect.height - imageLayout.height) / 2;
clampImagePosition();
updateSelectionSizeFromProps();
}
function zoomImage(event: WheelEvent) {
const zoomStep = event.deltaY < 0 ? 1.1 : 0.9;
imageLayout.zoom = clamp(imageLayout.zoom * zoomStep, 1, 6);
updateImageLayout();
}
function startDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage) return;
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget as HTMLElement;
target.setPointerCapture(event.pointerId);
dragState.active = true;
dragState.pointerId = event.pointerId;
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.selectionX = selection.x;
dragState.selectionY = selection.y;
}
function startImageDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage || !canDragImage()) return;
event.preventDefault();
stage.setPointerCapture(event.pointerId);
imageDragState.active = true;
imageDragState.pointerId = event.pointerId;
imageDragState.startX = event.clientX;
imageDragState.startY = event.clientY;
imageDragState.imageLeft = imageLayout.left;
imageDragState.imageTop = imageLayout.top;
}
function dragImage(event: PointerEvent) {
if (!imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
imageLayout.left = imageDragState.imageLeft + event.clientX - imageDragState.startX;
imageLayout.top = imageDragState.imageTop + event.clientY - imageDragState.startY;
clampImagePosition();
clampSelectionToImage();
cropImage();
}
function stopImageDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage || !imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
if (stage.hasPointerCapture(event.pointerId)) {
stage.releasePointerCapture(event.pointerId);
}
imageDragState.active = false;
}
function dragSelection(event: PointerEvent) {
const stage = stageRef.value;
if (!dragState.active || event.pointerId !== dragState.pointerId || !stage) return;
const rect = stage.getBoundingClientRect();
const dx = ((event.clientX - dragState.startX) / rect.width) * 100;
const dy = ((event.clientY - dragState.startY) / rect.height) * 100;
selection.x = clamp(dragState.selectionX + dx, 0, maxX.value);
selection.y = clamp(dragState.selectionY + dy, 0, maxY.value);
clampSelectionToImage();
}
function stopDrag(event: PointerEvent) {
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
dragState.active = false;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function updateSelectionSizeFromProps() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return;
const maxMaskWidth = imageLayout.width ? Math.min(rect.width, imageLayout.width) : rect.width;
const maxMaskHeight = imageLayout.height ? Math.min(rect.height, imageLayout.height) : rect.height;
selection.width = sizePixelsToPercent(props.width, rect.width, maxMaskWidth);
selection.height = sizePixelsToPercent(props.height, rect.height, maxMaskHeight);
clampSelectionToImage();
cropImage();
}
function sizePixelsToPercent(value: number, total: number, maxValue = total) {
return (clamp(value, 10, maxValue) / total) * 100;
}
function positionPixelsToPercent(value: number, total: number) {
return (clamp(value, 0, total) / total) * 100;
}
function clampSelectionToImage() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return;
if (!imageLayout.width || !imageLayout.height) {
selection.x = Math.min(selection.x, maxX.value);
selection.y = Math.min(selection.y, maxY.value);
return;
}
const maskWidth = (selection.width / 100) * rect.width;
const maskHeight = (selection.height / 100) * rect.height;
const minX = clamp(imageLayout.left, 0, rect.width - maskWidth);
const minY = clamp(imageLayout.top, 0, rect.height - maskHeight);
const maxXPosition = clamp(
imageLayout.left + imageLayout.width - maskWidth,
minX,
rect.width - maskWidth,
);
const maxYPosition = clamp(
imageLayout.top + imageLayout.height - maskHeight,
minY,
rect.height - maskHeight,
);
selection.x = positionPixelsToPercent(
clamp((selection.x / 100) * rect.width, minX, maxXPosition),
rect.width,
);
selection.y = positionPixelsToPercent(
clamp((selection.y / 100) * rect.height, minY, maxYPosition),
rect.height,
);
}
function canDragImage() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return false;
return imageLayout.width > rect.width || imageLayout.height > rect.height;
}
function clampImagePosition() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height || !imageLayout.width || !imageLayout.height) return;
if (imageLayout.width <= rect.width) {
imageLayout.left = (rect.width - imageLayout.width) / 2;
} else {
imageLayout.left = clamp(imageLayout.left, rect.width - imageLayout.width, 0);
}
if (imageLayout.height <= rect.height) {
imageLayout.top = (rect.height - imageLayout.height) / 2;
} else {
imageLayout.top = clamp(imageLayout.top, rect.height - imageLayout.height, 0);
}
}
</script>
<style scoped>
.cropper-image {
display: grid;
gap: 16px;
width: min(640px, 90vw);
padding: 16px;
border: 1px solid #d0d0d0;
background: #fff;
}
.cropper-stage {
position: relative;
height: 420px;
overflow: hidden;
background: #ddd;
cursor: grab;
touch-action: none;
user-select: none;
}
.cropper-stage:active {
cursor: grabbing;
}
.cropper-source {
position: absolute;
display: block;
max-width: none;
user-select: none;
}
.cropper-mask {
position: absolute;
border: 2px solid #1976d2;
box-shadow: 0 0 0 9999px rgb(0 0 0 / 45%);
cursor: move;
touch-action: none;
user-select: none;
}
.cropper-controls {
display: grid;
gap: 10px;
}
.cropper-controls button {
justify-self: start;
padding: 8px 14px;
border: 0;
color: #fff;
background: #1976d2;
cursor: pointer;
}
.cropper-preview {
width: 220px;
height: 220px;
border: 1px solid #d0d0d0;
background: #f5f5f5;
}
.cropper-preview--hidden {
display: none;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<q-item
clickable
:to="profilesStore.isAuthenticated ? '/profile' : '/login'"
class="drawer-user-card"
>
<q-item-section avatar>
<q-avatar size="48px">
<img v-if="profile?.avatar_url" :src="profile.avatar_url" />
<q-icon v-else name="person" size="32px" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">
{{ profileName }}
</q-item-label>
<q-item-label v-if="profile?.email" caption>
{{ profile.email }}
</q-item-label>
</q-item-section>
<q-item-section v-if="profilesStore.isAuthenticated" side>
<div class="column items-center">
<q-btn
flat
round
dense
icon="edit"
:aria-label="t('actions.editProfile')"
@click.stop.prevent="editProfile"
>
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
</q-btn>
<q-btn
flat
round
dense
icon="logout"
:aria-label="t('nav.logoutAction')"
@click.stop.prevent="logout"
>
<q-tooltip>{{ t('nav.logoutAction') }}</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useProfilesStore } from '@/stores/profiles-store';
const profilesStore = useProfilesStore();
const router = useRouter();
const { t } = useI18n();
const profile = computed(() => profilesStore.profile);
const profileName = computed(() => {
if (!profilesStore.isAuthenticated) return t('nav.login');
return profile.value?.display_name || profile.value?.email || '';
});
async function logout() {
profilesStore.logout();
await router.push('/');
}
async function editProfile() {
await router.push({ path: '/profile', query: { mode: 'edit' } });
}
</script>
<style scoped>
.drawer-user-card {
min-height: 72px;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<q-item
clickable
tag="a"
target="_blank"
:href="link"
>
<q-item-section
v-if="icon"
avatar
>
<q-icon :name="icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ label }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
export interface EssentialLinkProps {
label: string;
caption?: string;
link?: string;
icon?: string;
};
withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',
link: '#',
icon: '',
});
</script>

View File

@@ -0,0 +1,23 @@
<template>
<q-badge :color="statusInfo(status).color">
{{ statusLabel }}
</q-badge>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { statusInfo, type Status } from '@/stores/profiles-store';
const props = defineProps<{
status: Status;
}>();
const { t } = useI18n();
const statusLabel = computed(() => {
const key = `status.${props.status}`;
const translated = t(key);
return translated === key ? t('status.unknown', { status: props.status }) : translated;
});
</script>

19
frontend/src/css/app.css Normal file
View File

@@ -0,0 +1,19 @@
/* app global css */
.q-field input:-webkit-autofill,
.q-field input:-webkit-autofill:hover,
.q-field input:-webkit-autofill:focus,
.q-field textarea:-webkit-autofill,
.q-field textarea:-webkit-autofill:hover,
.q-field textarea:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
box-shadow: 0 0 0 1000px #ffffff inset !important;
-webkit-text-fill-color: currentColor !important;
caret-color: currentColor !important;
transition: background-color 999999s ease-out;
}
.q-field:has(input:-webkit-autofill) .q-field__control,
.q-field:has(textarea:-webkit-autofill) .q-field__control {
background-color: #ffffff !important;
}

View File

@@ -0,0 +1,257 @@
export const countries = [
{'CH' : 'Switzerland'},
{'IT' : 'Italy'},
{'FR' : 'France'},
{'DE' : 'Germany'},
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
{'US' : 'United States of America (the)'},
{'AF' : 'Afghanistan'},
{'AL' : 'Albania'},
{'DZ' : 'Algeria'},
{'AS' : 'American Samoa'},
{'AD' : 'Andorra'},
{'AO' : 'Angola'},
{'AI' : 'Anguilla'},
{'AQ' : 'Antarctica'},
{'AG' : 'Antigua and Barbuda'},
{'AR' : 'Argentina'},
{'AM' : 'Armenia'},
{'AW' : 'Aruba'},
{'AU' : 'Australia'},
{'AT' : 'Austria'},
{'AZ' : 'Azerbaijan'},
{'BS' : 'Bahamas (The)'},
{'BH' : 'Bahrain'},
{'BD' : 'Bangladesh'},
{'BB' : 'Barbados'},
{'BY' : 'Belarus'},
{'BE' : 'Belgium'},
{'BZ' : 'Belize'},
{'BJ' : 'Benin'},
{'BM' : 'Bermuda'},
{'BT' : 'Bhutan'},
{'BO' : 'Bolivia (Plurinational State of)'},
{'BQ' : 'Bonaire, Sint Eustatius and Saba'},
{'BA' : 'Bosnia and Herzegovina'},
{'BW' : 'Botswana'},
{'BV' : 'Bouvet Island'},
{'BR' : 'Brazil'},
{'IO' : 'British Indian Ocean Territory (the)'},
{'BN' : 'Brunei Darussalam'},
{'BG' : 'Bulgaria'},
{'BF' : 'Burkina Faso'},
{'BI' : 'Burundi'},
{'CV' : 'Cabo Verde'},
{'KH' : 'Cambodia'},
{'CM' : 'Cameroon'},
{'CA' : 'Canada'},
{'KY' : 'Cayman Islands (the)'},
{'CF' : 'Central African Republic (the)'},
{'TD' : 'Chad'},
{'CL' : 'Chile'},
{'CN' : 'China'},
{'CX' : 'Christmas Island'},
{'CC' : 'Cocos (Keeling) Islands (the)'},
{'CO' : 'Colombia'},
{'KM' : 'Comoros (the)'},
{'CD' : 'Congo (the Democratic Republic of the)'},
{'CG' : 'Congo (the)'},
{'CK' : 'Cook Islands (the)'},
{'CR' : 'Costa Rica'},
{'HR' : 'Croatia'},
{'CU' : 'Cuba'},
{'CW' : 'Curaçao'},
{'CY' : 'Cyprus'},
{'CZ' : 'Czechia'},
{'CI' : "Côte d'Ivoire"},
{'DK' : 'Denmark'},
{'DJ' : 'Djibouti'},
{'DM' : 'Dominica'},
{'DO' : 'Dominican Republic (the)'},
{'EC' : 'Ecuador'},
{'EG' : 'Egypt'},
{'SV' : 'El Salvador'},
{'GQ' : 'Equatorial Guinea'},
{'ER' : 'Eritrea'},
{'EE' : 'Estonia'},
{'SZ' : 'Eswatini'},
{'ET' : 'Ethiopia'},
{'FK' : 'Falkland Islands (the) [Malvinas]'},
{'FO' : 'Faroe Islands (the)'},
{'FJ' : 'Fiji'},
{'FI' : 'Finland'},
{'FR' : 'France'},
{'GF' : 'French Guiana'},
{'PF' : 'French Polynesia'},
{'TF' : 'French Southern Territories (the)'},
{'GA' : 'Gabon'},
{'GM' : 'Gambia (the)'},
{'GE' : 'Georgia'},
{'GH' : 'Ghana'},
{'GI' : 'Gibraltar'},
{'GR' : 'Greece'},
{'GL' : 'Greenland'},
{'GD' : 'Grenada'},
{'GP' : 'Guadeloupe'},
{'GU' : 'Guam'},
{'GT' : 'Guatemala'},
{'GG' : 'Guernsey'},
{'GN' : 'Guinea'},
{'GW' : 'Guinea-Bissau'},
{'GY' : 'Guyana'},
{'HT' : 'Haiti'},
{'HM' : 'Heard Island and McDonald Islands'},
{'VA' : 'Holy See (the)'},
{'HN' : 'Honduras'},
{'HK' : 'Hong Kong'},
{'HU' : 'Hungary'},
{'IS' : 'Iceland'},
{'IN' : 'India'},
{'ID' : 'Indonesia'},
{'IR' : 'Iran (Islamic Republic of)'},
{'IQ' : 'Iraq'},
{'IE' : 'Ireland'},
{'IM' : 'Isle of Man'},
{'IL' : 'Israel'},
{'JM' : 'Jamaica'},
{'JP' : 'Japan'},
{'JE' : 'Jersey'},
{'JO' : 'Jordan'},
{'KZ' : 'Kazakhstan'},
{'KE' : 'Kenya'},
{'KI' : 'Kiribati'},
{'KP' : "Korea (the Democratic People's Republic of)"},
{'KR' : 'Korea (the Republic of)'},
{'KW' : 'Kuwait'},
{'KG' : 'Kyrgyzstan'},
{'LA' : "Lao People's Democratic Republic (the)"},
{'LV' : 'Latvia'},
{'LB' : 'Lebanon'},
{'LS' : 'Lesotho'},
{'LR' : 'Liberia'},
{'LY' : 'Libya'},
{'LI' : 'Liechtenstein'},
{'LT' : 'Lithuania'},
{'LU' : 'Luxembourg'},
{'MO' : 'Macao'},
{'MG' : 'Madagascar'},
{'MW' : 'Malawi'},
{'MY' : 'Malaysia'},
{'MV' : 'Maldives'},
{'ML' : 'Mali'},
{'MT' : 'Malta'},
{'MH' : 'Marshall Islands (the)'},
{'MQ' : 'Martinique'},
{'MR' : 'Mauritania'},
{'MU' : 'Mauritius'},
{'YT' : 'Mayotte'},
{'MX' : 'Mexico'},
{'FM' : 'Micronesia (Federated States of)'},
{'MD' : 'Moldova (the Republic of)'},
{'MC' : 'Monaco'},
{'MN' : 'Mongolia'},
{'ME' : 'Montenegro'},
{'MS' : 'Montserrat'},
{'MA' : 'Morocco'},
{'MZ' : 'Mozambique'},
{'MM' : 'Myanmar'},
{'NA' : 'Namibia'},
{'NR' : 'Nauru'},
{'NP' : 'Nepal'},
{'NL' : 'Netherlands (Kingdom of the)'},
{'NC' : 'New Caledonia'},
{'NZ' : 'New Zealand'},
{'NI' : 'Nicaragua'},
{'NE' : 'Niger (the)'},
{'NG' : 'Nigeria'},
{'NU' : 'Niue'},
{'NF' : 'Norfolk Island'},
{'MK' : 'North Macedonia'},
{'MP' : 'Northern Mariana Islands (the)'},
{'NO' : 'Norway'},
{'OM' : 'Oman'},
{'PK' : 'Pakistan'},
{'PW' : 'Palau'},
{'PS' : 'Palestine, State of'},
{'PA' : 'Panama'},
{'PG' : 'Papua New Guinea'},
{'PY' : 'Paraguay'},
{'PE' : 'Peru'},
{'PH' : 'Philippines (the)'},
{'PN' : 'Pitcairn'},
{'PL' : 'Poland'},
{'PT' : 'Portugal'},
{'PR' : 'Puerto Rico'},
{'QA' : 'Qatar'},
{'RO' : 'Romania'},
{'RU' : 'Russian Federation (the)'},
{'RW' : 'Rwanda'},
{'RE' : 'Réunion'},
{'BL' : 'Saint Barthélemy'},
{'SH' : 'Saint Helena, Ascension and Tristan da Cunha'},
{'KN' : 'Saint Kitts and Nevis'},
{'LC' : 'Saint Lucia'},
{'MF' : 'Saint Martin (French part)'},
{'PM' : 'Saint Pierre and Miquelon'},
{'VC' : 'Saint Vincent and the Grenadines'},
{'WS' : 'Samoa'},
{'SM' : 'San Marino'},
{'ST' : 'Sao Tome and Principe'},
{'SA' : 'Saudi Arabia'},
{'SN' : 'Senegal'},
{'RS' : 'Serbia'},
{'SC' : 'Seychelles'},
{'SL' : 'Sierra Leone'},
{'SG' : 'Singapore'},
{'SX' : 'Sint Maarten (Dutch part)'},
{'SK' : 'Slovakia'},
{'SI' : 'Slovenia'},
{'SB' : 'Solomon Islands'},
{'SO' : 'Somalia'},
{'ZA' : 'South Africa'},
{'GS' : 'South Georgia and the South Sandwich Islands'},
{'SS' : 'South Sudan'},
{'ES' : 'Spain'},
{'LK' : 'Sri Lanka'},
{'SD' : 'Sudan (the)'},
{'SR' : 'Suriname'},
{'SJ' : 'Svalbard and Jan Mayen'},
{'SE' : 'Sweden'},
{'SY' : 'Syrian Arab Republic (the)'},
{'TW' : 'Taiwan (Province of China)'},
{'TJ' : 'Tajikistan'},
{'TZ' : 'Tanzania, the United Republic of'},
{'TH' : 'Thailand'},
{'TL' : 'Timor-Leste'},
{'TG' : 'Togo'},
{'TK' : 'Tokelau'},
{'TO' : 'Tonga'},
{'TT' : 'Trinidad and Tobago'},
{'TN' : 'Tunisia'},
{'TM' : 'Turkmenistan'},
{'TC' : 'Turks and Caicos Islands (the)'},
{'TV' : 'Tuvalu'},
{'TR' : 'Türkiye'},
{'UG' : 'Uganda'},
{'UA' : 'Ukraine'},
{'AE' : 'United Arab Emirates (the)'},
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
{'UM' : 'United States Minor Outlying Islands (the)'},
{'UY' : 'Uruguay'},
{'UZ' : 'Uzbekistan'},
{'VU' : 'Vanuatu'},
{'VE' : 'Venezuela (Bolivarian Republic of)'},
{'VN' : 'Viet Nam'},
{'VG' : 'Virgin Islands (British)'},
{'VI' : 'Virgin Islands (U.S.)'},
{'WF' : 'Wallis and Futuna'},
{'EH' : 'Western Sahara*'},
{'YE' : 'Yemen'},
{'ZM' : 'Zambia'},
{'ZW' : 'Zimbabwe'},
]

1419
frontend/src/encore/client.ts Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.
// Existing field validators are preserved when this file is synced again.
import { z } from 'zod';
export const AdminPersonalDataParamsSchema = z.object({
first_name: z.string().min(2).max(32),
last_name: z.string().min(2).max(32),
address: z.string().min(5).max(32),
cap: z.string(),
city: z.string().min(2).max(32),
country: z.string(),
});
export const AdminProfileParamsSchema = z.object({
display_name: z.string().min(2).max(32),
avatar_url: z.string(),
});
export const AdminRegisterParamsSchema = z.object({
email: z.string(),
display_name: z.string(),
avatar_url: z.string(),
password: z.string(),
});
export const AdminUpdateProfileArtistParamsSchema = z.object({
is_artist: z.boolean(),
});
export const AdminUpdateProfilePasswordParamsSchema = z.object({
password: z.string(),
});
export const AdminUpdateProfileRoleParamsSchema = z.object({
role: z.string(),
});
export const AdminUpdateProfileStatusParamsSchema = z.object({
status: z.number(),
});
export const AuthSetPasswordParamsSchema = z.object({
current_password: z.string(),
password: z.string(),
});
export const ProfilesLoginParamsSchema = z.object({
user_email: z.string(),
password: z.string(),
});
export const ProfilesPersonalDataParamsSchema = z.object({
first_name: z.string(),
last_name: z.string(),
address: z.string(),
cap: z.string(),
city: z.string(),
country: z.string(),
});
export const ProfilesProfileParamsSchema = z.object({
display_name: z.string(),
avatar_url: z.string(),
});
export const ProfilesRegisterParamsSchema = z.object({
email: z.string(),
display_name: z.string(),
avatar_url: z.string(),
password: z.string(),
});
export const RegistrationRegisterParamsSchema = z.object({
email: z.string().email(),
display_name: z.string().min(2).max(32),
avatar_url: z.string(),
password: z.string().min(8),
});
export const RegistrationPasswordResetConfirmParamsSchema = z.object({
password: z.string(),
});
export const RegistrationPasswordResetRequestParamsSchema = z.object({
email: z.string(),
});

688
frontend/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,688 @@
import { createI18n } from 'vue-i18n';
export type SupportedLocale = 'en' | 'it' | 'fr' | 'de' | 'es';
export const localeOptions: { label: string; value: SupportedLocale }[] = [
{ label: 'English', value: 'en' },
{ label: 'Italiano', value: 'it' },
{ label: 'Français', value: 'fr' },
{ label: 'Deutsch', value: 'de' },
{ label: 'Español', value: 'es' },
];
const fallbackLocale: SupportedLocale = 'en';
const storageKey = 'app.locale';
const messages = {
en: {
app: { title: 'Encore Profiles' },
nav: {
menu: 'Menu',
profiles: 'Profiles',
myProfile: 'My profile',
login: 'Log in',
register: 'Register',
logout: 'Log out ({name})',
logoutAction: 'Log out',
language: 'Language',
},
actions: {
cancel: 'Cancel',
save: 'Save',
create: 'Create',
remove: 'Remove',
chooseFile: 'Choose file',
camera: 'Camera',
takePhoto: 'Take photo',
crop: 'Crop',
newProfile: 'New profile',
register: 'Register',
editProfile: 'Edit profile',
updateRole: 'Update role',
updateStatus: 'Update status',
updatePassword: 'Update password',
},
fields: {
email: 'Email',
password: 'Password',
displayName: 'Display name',
firstName: 'First name',
lastName: 'Last name',
address: 'Address',
cap: 'Postcode',
city: 'City',
country: 'Country',
role: 'Role',
status: 'Status',
},
avatar: {
cropAvatar: 'Crop avatar',
cameraUnavailable: 'Camera not available in this browser.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profiles',
personalData: 'Personal data',
profile: 'Profile',
notArtist: 'Not an artist',
artist: 'Artist',
columns: {
name: 'Name',
email: 'Email',
role: 'Role',
status: 'Status',
artist: 'Artist',
created: 'Created',
updated: 'Updated',
},
notifications: {
profileCreated: 'Profile created.',
profileUpdated: 'Profile updated.',
personalDataUpdated: 'Personal data updated.',
roleUpdated: 'Role updated.',
statusUpdated: 'Status updated.',
passwordUpdated: 'Password updated.',
},
},
profile: {
title: 'My profile',
avatar: 'Avatar',
password: 'Password',
currentPassword: 'Current password',
newPassword: 'New password',
confirmNewPassword: 'Confirm new password',
passwordMismatch: 'Passwords do not match.',
loginRequired: 'You must be logged in to view your profile.',
},
login: {
title: 'Log in',
needAccount: 'Create an account',
forgotPassword: 'Forgot password',
},
passwordReset: {
title: 'Password reset',
newPasswordTitle: 'Choose a new password',
sendLink: 'Send reset link',
updatePassword: 'Update password',
backToLogin: 'Back to log in',
requestSent: 'If the email exists, we sent a password reset link.',
resetDone: 'Password updated. You can now log in.',
},
register: {
title: 'Create account',
welcomeTitle: 'Welcome',
haveAccount: 'I already have an account',
success: 'Registration completed. You can now log in.',
confirmationSent: 'We sent you a confirmation request. Please check your mailbox.',
emailUnavailable: 'This email is already registered.',
emailDomainInvalid: 'This email domain cannot receive email.',
},
welcome: {
title: 'Email confirmation',
success: 'Email confirmed for {email}.',
missingToken: 'Missing confirmation token.',
},
pages: {
home: 'Home',
goHome: 'Go Home',
goToIndex: 'Go to Index Page',
goToSecond: 'Go to Second Page',
cropperExample: 'Cropper example',
notFound: 'Oops. Nothing here...',
unauthorized: 'Unauthorized',
unauthorizedHint: 'You need to log in to access this page.',
},
status: {
0: 'Active',
1: 'Inactive',
2: 'Suspended',
3: 'Deleted',
4: 'Pending',
5: 'Waiting deletion',
6: 'Banned',
unknown: 'Unknown ({status})',
},
},
it: {
app: { title: 'Profili Encore' },
nav: {
menu: 'Menu',
profiles: 'Profili',
myProfile: 'Il mio profilo',
login: 'Accedi',
register: 'Registrati',
logout: 'Esci ({name})',
logoutAction: 'Esci',
language: 'Lingua',
},
actions: {
cancel: 'Annulla',
save: 'Salva',
create: 'Crea',
remove: 'Rimuovi',
chooseFile: 'Scegli file',
camera: 'Fotocamera',
takePhoto: 'Scatta foto',
crop: 'Ritaglia',
newProfile: 'Nuovo profilo',
register: 'Registrati',
editProfile: 'Modifica profilo',
updateRole: 'Aggiorna ruolo',
updateStatus: 'Aggiorna stato',
updatePassword: 'Aggiorna password',
},
fields: {
email: 'Email',
password: 'Password',
displayName: 'Nome visualizzato',
firstName: 'Nome',
lastName: 'Cognome',
address: 'Indirizzo',
cap: 'CAP',
city: 'Città',
country: 'Paese',
role: 'Ruolo',
status: 'Stato',
},
avatar: {
cropAvatar: 'Ritaglia avatar',
cameraUnavailable: 'Fotocamera non disponibile in questo browser.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profili',
personalData: 'Dati personali',
profile: 'Profilo',
notArtist: 'Non artista',
artist: 'Artista',
columns: {
name: 'Nome',
email: 'Email',
role: 'Ruolo',
status: 'Stato',
artist: 'Artista',
created: 'Creato',
updated: 'Aggiornato',
},
notifications: {
profileCreated: 'Profilo creato.',
profileUpdated: 'Profilo aggiornato.',
personalDataUpdated: 'Dati personali aggiornati.',
roleUpdated: 'Ruolo aggiornato.',
statusUpdated: 'Stato aggiornato.',
passwordUpdated: 'Password aggiornata.',
},
},
profile: {
title: 'Il mio profilo',
avatar: 'Avatar',
password: 'Password',
currentPassword: 'Password corrente',
newPassword: 'Nuova password',
confirmNewPassword: 'Conferma nuova password',
passwordMismatch: 'Le password non coincidono.',
loginRequired: 'Devi effettuare laccesso per visualizzare il profilo.',
},
login: {
title: 'Accedi',
needAccount: 'Crea un account',
forgotPassword: 'Password dimenticata',
},
passwordReset: {
title: 'Recupero password',
newPasswordTitle: 'Scegli una nuova password',
sendLink: 'Invia link di recupero',
updatePassword: 'Aggiorna password',
backToLogin: 'Torna al login',
requestSent: 'Se lemail esiste, abbiamo inviato un link per reimpostare la password.',
resetDone: 'Password aggiornata. Ora puoi accedere.',
},
register: {
title: 'Crea account',
welcomeTitle: 'Benvenuto',
haveAccount: 'Ho già un account',
success: 'Registrazione completata. Ora puoi accedere.',
confirmationSent: 'Ti abbiamo inviato una richieta di conferma, consulta la tua casella di posta.',
emailUnavailable: 'Questa email è già registrata.',
emailDomainInvalid: 'Il dominio di questa email non può ricevere posta.',
},
welcome: {
title: 'Conferma email',
success: 'Email confermata per {email}.',
missingToken: 'Token di conferma mancante.',
},
pages: {
home: 'Home',
goHome: 'Vai alla home',
goToIndex: 'Vai alla pagina iniziale',
goToSecond: 'Vai alla seconda pagina',
cropperExample: 'Esempio cropper',
notFound: 'Ops. Qui non cè niente...',
unauthorized: 'Non autorizzato',
unauthorizedHint: 'Devi effettuare laccesso per visualizzare questa pagina.',
},
status: {
0: 'Attivo',
1: 'Inattivo',
2: 'Sospeso',
3: 'Eliminato',
4: 'In attesa',
5: 'In attesa di eliminazione',
6: 'Bannato',
unknown: 'Sconosciuto ({status})',
},
},
fr: {
app: { title: 'Profils Encore' },
nav: {
menu: 'Menu',
profiles: 'Profils',
myProfile: 'Mon profil',
login: 'Connexion',
register: 'Inscription',
logout: 'Déconnexion ({name})',
logoutAction: 'Déconnexion',
language: 'Langue',
},
actions: {
cancel: 'Annuler',
save: 'Enregistrer',
create: 'Créer',
remove: 'Supprimer',
chooseFile: 'Choisir un fichier',
camera: 'Caméra',
takePhoto: 'Prendre une photo',
crop: 'Recadrer',
newProfile: 'Nouveau profil',
register: 'Sinscrire',
editProfile: 'Modifier le profil',
updateRole: 'Modifier le rôle',
updateStatus: 'Modifier le statut',
updatePassword: 'Modifier le mot de passe',
},
fields: {
email: 'Email',
password: 'Mot de passe',
displayName: 'Nom affiché',
firstName: 'Prénom',
lastName: 'Nom',
address: 'Adresse',
cap: 'Code postal',
city: 'Ville',
country: 'Pays',
role: 'Rôle',
status: 'Statut',
},
avatar: {
cropAvatar: 'Recadrer lavatar',
cameraUnavailable: 'Caméra non disponible dans ce navigateur.',
},
admin: {
dashboard: 'Tableau de bord',
profiles: 'Profils',
personalData: 'Données personnelles',
profile: 'Profil',
notArtist: 'Pas artiste',
artist: 'Artiste',
columns: {
name: 'Nom',
email: 'Email',
role: 'Rôle',
status: 'Statut',
artist: 'Artiste',
created: 'Créé',
updated: 'Mis à jour',
},
notifications: {
profileCreated: 'Profil créé.',
profileUpdated: 'Profil mis à jour.',
personalDataUpdated: 'Données personnelles mises à jour.',
roleUpdated: 'Rôle mis à jour.',
statusUpdated: 'Statut mis à jour.',
passwordUpdated: 'Mot de passe mis à jour.',
},
},
profile: {
title: 'Mon profil',
avatar: 'Avatar',
password: 'Mot de passe',
currentPassword: 'Mot de passe actuel',
newPassword: 'Nouveau mot de passe',
confirmNewPassword: 'Confirmer le nouveau mot de passe',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
loginRequired: 'Vous devez être connecté pour voir votre profil.',
},
login: {
title: 'Connexion',
needAccount: 'Créer un compte',
forgotPassword: 'Mot de passe oublié',
},
passwordReset: {
title: 'Réinitialisation du mot de passe',
newPasswordTitle: 'Choisir un nouveau mot de passe',
sendLink: 'Envoyer le lien',
updatePassword: 'Mettre à jour le mot de passe',
backToLogin: 'Retour à la connexion',
requestSent: 'Si lemail existe, nous avons envoyé un lien de réinitialisation.',
resetDone: 'Mot de passe mis à jour. Vous pouvez maintenant vous connecter.',
},
register: {
title: 'Créer un compte',
welcomeTitle: 'Bienvenue',
haveAccount: 'Jai déjà un compte',
success: 'Inscription terminée. Vous pouvez maintenant vous connecter.',
confirmationSent: 'Nous vous avons envoyé une demande de confirmation. Veuillez consulter votre boîte mail.',
emailUnavailable: 'Cet email est déjà enregistré.',
emailDomainInvalid: 'Ce domaine email ne peut pas recevoir de-mails.',
},
welcome: {
title: 'Confirmation email',
success: 'Email confirmée pour {email}.',
missingToken: 'Jeton de confirmation manquant.',
},
pages: {
home: 'Accueil',
goHome: 'Accueil',
goToIndex: 'Aller à la page daccueil',
goToSecond: 'Aller à la deuxième page',
cropperExample: 'Exemple de recadrage',
notFound: 'Oups. Rien ici...',
unauthorized: 'Non autorisé',
unauthorizedHint: 'Vous devez vous connecter pour accéder à cette page.',
},
status: {
0: 'Actif',
1: 'Inactif',
2: 'Suspendu',
3: 'Supprimé',
4: 'En attente',
5: 'Suppression en attente',
6: 'Banni',
unknown: 'Inconnu ({status})',
},
},
de: {
app: { title: 'Encore Profile' },
nav: {
menu: 'Menü',
profiles: 'Profile',
myProfile: 'Mein Profil',
login: 'Anmelden',
register: 'Registrieren',
logout: 'Abmelden ({name})',
logoutAction: 'Abmelden',
language: 'Sprache',
},
actions: {
cancel: 'Abbrechen',
save: 'Speichern',
create: 'Erstellen',
remove: 'Entfernen',
chooseFile: 'Datei wählen',
camera: 'Kamera',
takePhoto: 'Foto aufnehmen',
crop: 'Zuschneiden',
newProfile: 'Neues Profil',
register: 'Registrieren',
editProfile: 'Profil bearbeiten',
updateRole: 'Rolle ändern',
updateStatus: 'Status ändern',
updatePassword: 'Passwort ändern',
},
fields: {
email: 'E-Mail',
password: 'Passwort',
displayName: 'Anzeigename',
firstName: 'Vorname',
lastName: 'Nachname',
address: 'Adresse',
cap: 'Postleitzahl',
city: 'Stadt',
country: 'Land',
role: 'Rolle',
status: 'Status',
},
avatar: {
cropAvatar: 'Avatar zuschneiden',
cameraUnavailable: 'Kamera ist in diesem Browser nicht verfügbar.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profile',
personalData: 'Persönliche Daten',
profile: 'Profil',
notArtist: 'Kein Künstler',
artist: 'Künstler',
columns: {
name: 'Name',
email: 'E-Mail',
role: 'Rolle',
status: 'Status',
artist: 'Künstler',
created: 'Erstellt',
updated: 'Aktualisiert',
},
notifications: {
profileCreated: 'Profil erstellt.',
profileUpdated: 'Profil aktualisiert.',
personalDataUpdated: 'Persönliche Daten aktualisiert.',
roleUpdated: 'Rolle aktualisiert.',
statusUpdated: 'Status aktualisiert.',
passwordUpdated: 'Passwort aktualisiert.',
},
},
profile: {
title: 'Mein Profil',
avatar: 'Avatar',
password: 'Passwort',
currentPassword: 'Aktuelles Passwort',
newPassword: 'Neues Passwort',
confirmNewPassword: 'Neues Passwort bestätigen',
passwordMismatch: 'Die Passwörter stimmen nicht überein.',
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
},
login: {
title: 'Anmelden',
needAccount: 'Konto erstellen',
forgotPassword: 'Passwort vergessen',
},
passwordReset: {
title: 'Passwort zurücksetzen',
newPasswordTitle: 'Neues Passwort wählen',
sendLink: 'Link senden',
updatePassword: 'Passwort aktualisieren',
backToLogin: 'Zur Anmeldung',
requestSent: 'Wenn die E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.',
resetDone: 'Passwort aktualisiert. Sie können sich jetzt anmelden.',
},
register: {
title: 'Konto erstellen',
welcomeTitle: 'Willkommen',
haveAccount: 'Ich habe bereits ein Konto',
success: 'Registrierung abgeschlossen. Sie können sich jetzt anmelden.',
confirmationSent: 'Wir haben Ihnen eine Bestätigungsanfrage gesendet. Bitte prüfen Sie Ihr Postfach.',
emailUnavailable: 'Diese E-Mail ist bereits registriert.',
emailDomainInvalid: 'Diese E-Mail-Domain kann keine E-Mails empfangen.',
},
welcome: {
title: 'E-Mail-Bestätigung',
success: 'E-Mail für {email} bestätigt.',
missingToken: 'Bestätigungstoken fehlt.',
},
pages: {
home: 'Startseite',
goHome: 'Zur Startseite',
goToIndex: 'Zur Startseite',
goToSecond: 'Zur zweiten Seite',
cropperExample: 'Cropper-Beispiel',
notFound: 'Hoppla. Hier ist nichts...',
unauthorized: 'Nicht autorisiert',
unauthorizedHint: 'Sie müssen angemeldet sein, um diese Seite aufzurufen.',
},
status: {
0: 'Aktiv',
1: 'Inaktiv',
2: 'Gesperrt',
3: 'Gelöscht',
4: 'Ausstehend',
5: 'Löschung ausstehend',
6: 'Gebannt',
unknown: 'Unbekannt ({status})',
},
},
es: {
app: { title: 'Perfiles Encore' },
nav: {
menu: 'Menú',
profiles: 'Perfiles',
myProfile: 'Mi perfil',
login: 'Iniciar sesión',
register: 'Registrarse',
logout: 'Cerrar sesión ({name})',
logoutAction: 'Cerrar sesión',
language: 'Idioma',
},
actions: {
cancel: 'Cancelar',
save: 'Guardar',
create: 'Crear',
remove: 'Eliminar',
chooseFile: 'Elegir archivo',
camera: 'Cámara',
takePhoto: 'Tomar foto',
crop: 'Recortar',
newProfile: 'Nuevo perfil',
register: 'Registrarse',
editProfile: 'Editar perfil',
updateRole: 'Actualizar rol',
updateStatus: 'Actualizar estado',
updatePassword: 'Actualizar contraseña',
},
fields: {
email: 'Email',
password: 'Contraseña',
displayName: 'Nombre visible',
firstName: 'Nombre',
lastName: 'Apellido',
address: 'Dirección',
cap: 'Código postal',
city: 'Ciudad',
country: 'País',
role: 'Rol',
status: 'Estado',
},
avatar: {
cropAvatar: 'Recortar avatar',
cameraUnavailable: 'La cámara no está disponible en este navegador.',
},
admin: {
dashboard: 'Panel',
profiles: 'Perfiles',
personalData: 'Datos personales',
profile: 'Perfil',
notArtist: 'No artista',
artist: 'Artista',
columns: {
name: 'Nombre',
email: 'Email',
role: 'Rol',
status: 'Estado',
artist: 'Artista',
created: 'Creado',
updated: 'Actualizado',
},
notifications: {
profileCreated: 'Perfil creado.',
profileUpdated: 'Perfil actualizado.',
personalDataUpdated: 'Datos personales actualizados.',
roleUpdated: 'Rol actualizado.',
statusUpdated: 'Estado actualizado.',
passwordUpdated: 'Contraseña actualizada.',
},
},
profile: {
title: 'Mi perfil',
avatar: 'Avatar',
password: 'Contraseña',
currentPassword: 'Contraseña actual',
newPassword: 'Nueva contraseña',
confirmNewPassword: 'Confirmar nueva contraseña',
passwordMismatch: 'Las contraseñas no coinciden.',
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
},
login: {
title: 'Iniciar sesión',
needAccount: 'Crear una cuenta',
forgotPassword: 'Contraseña olvidada',
},
passwordReset: {
title: 'Recuperar contraseña',
newPasswordTitle: 'Elige una nueva contraseña',
sendLink: 'Enviar enlace',
updatePassword: 'Actualizar contraseña',
backToLogin: 'Volver al inicio de sesión',
requestSent: 'Si el email existe, enviamos un enlace para restablecer la contraseña.',
resetDone: 'Contraseña actualizada. Ahora puedes iniciar sesión.',
},
register: {
title: 'Crear cuenta',
welcomeTitle: 'Bienvenido',
haveAccount: 'Ya tengo una cuenta',
success: 'Registro completado. Ahora puedes iniciar sesión.',
confirmationSent: 'Te hemos enviado una solicitud de confirmación. Revisa tu buzón de correo.',
emailUnavailable: 'Este email ya está registrado.',
emailDomainInvalid: 'Este dominio de email no puede recibir correo.',
},
welcome: {
title: 'Confirmación de email',
success: 'Email confirmado para {email}.',
missingToken: 'Falta el token de confirmación.',
},
pages: {
home: 'Inicio',
goHome: 'Ir al inicio',
goToIndex: 'Ir a la página inicial',
goToSecond: 'Ir a la segunda página',
cropperExample: 'Ejemplo de recorte',
notFound: 'Vaya. Aquí no hay nada...',
unauthorized: 'No autorizado',
unauthorizedHint: 'Debes iniciar sesión para acceder a esta página.',
},
status: {
0: 'Activo',
1: 'Inactivo',
2: 'Suspendido',
3: 'Eliminado',
4: 'Pendiente',
5: 'Esperando eliminación',
6: 'Bloqueado',
unknown: 'Desconocido ({status})',
},
},
};
function detectLocale(): SupportedLocale {
const stored = window.localStorage.getItem(storageKey);
if (isSupportedLocale(stored)) return stored;
const browserLocale = navigator.language.split('-')[0] ?? null;
if (isSupportedLocale(browserLocale)) return browserLocale;
return fallbackLocale;
}
function isSupportedLocale(locale: string | null): locale is SupportedLocale {
return localeOptions.some((option) => option.value === locale);
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale,
messages,
});
export function setLocale(locale: SupportedLocale) {
i18n.global.locale.value = locale;
window.localStorage.setItem(storageKey, locale);
}

View File

@@ -0,0 +1,132 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated class="bg-warning text-black">
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
:aria-label="t('nav.menu')"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
{{ t('app.title') }}
</q-toolbar-title>
<q-select
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
emit-value
map-options
dense
borderless
class="q-ml-md language-select"
:aria-label="t('nav.language')"
/>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list>
<DrawerUserCard />
<q-separator />
<q-item clickable to="/">
<q-item-section avatar>
<q-icon name="home" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('pages.home') }}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable to="/admin/dashboard">
<q-item-section avatar>
<q-icon name="dashboard" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable to="/admin/profiles">
<q-item-section avatar>
<q-icon name="people" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('nav.profiles') }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import DrawerUserCard from '@/components/DrawerUserCard.vue';
import { useProfilesStore } from '@/stores/profiles-store';
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
const profilesStore = useProfilesStore();
const router = useRouter();
const { locale, t } = useI18n();
const selectedLocale = computed({
get: () => locale.value as SupportedLocale,
set: (value: SupportedLocale) => setLocale(value),
});
onMounted(async () => {
try {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
await profilesStore.me();
}
} catch {
profilesStore.logout();
}
if (!profilesStore.isAuthenticated || profilesStore.profile?.role !== 'admin') {
await router.replace('/401');
}
});
const leftDrawerOpen = ref(false);
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout() {
profilesStore.logout();
await router.push('/');
}
</script>
<style scoped>
.language-select {
width: 90px;
max-width: 90px;
color: #000;
font-weight: 700;
}
.language-select :deep(.q-field__native),
.language-select :deep(.q-field__append),
.language-select :deep(.q-icon) {
color: #000;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
:aria-label="t('nav.menu')"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
{{ t('app.title') }}
</q-toolbar-title>
<q-select
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
emit-value
map-options
dense
borderless
class="q-ml-md language-select"
:aria-label="t('nav.language')"
/>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list>
<DrawerUserCard />
<q-separator />
<q-item v-if="isAdmin" clickable to="/admin/dashboard">
<q-item-section avatar>
<q-icon name="dashboard" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
</q-item-section>
</q-item>
<q-item-label
header
>
Essential Links
</q-item-label>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import DrawerUserCard from '@/components/DrawerUserCard.vue';
import { useProfilesStore } from '@/stores/profiles-store';
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
const profilesStore = useProfilesStore();
const router = useRouter();
const { locale, t } = useI18n();
const selectedLocale = computed({
get: () => locale.value as SupportedLocale,
set: (value: SupportedLocale) => setLocale(value),
});
const isAdmin = computed(() => profilesStore.profile?.role === 'admin');
onMounted(async () => {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
try {
await profilesStore.me();
} catch {
profilesStore.logout();
}
}
});
const leftDrawerOpen = ref(false);
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout() {
profilesStore.logout();
await router.push('/');
}
</script>
<style scoped>
.language-select {
width: 90px;
max-width: 90px;
color: #fff;
font-weight: 700;
}
.language-select :deep(.q-field__native),
.language-select :deep(.q-field__append),
.language-select :deep(.q-icon) {
color: #fff;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
{{ $t('pages.notFound') }}
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
:label="$t('pages.goHome')"
no-caps
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<div class="fullscreen bg-deep-orange text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
401
</div>
<div class="text-h2" style="opacity:.55">
{{ $t('pages.unauthorized') }}
</div>
<div class="text-subtitle1 q-mt-md" style="opacity:.8">
{{ $t('pages.unauthorizedHint') }}
</div>
<div class="row justify-center q-gutter-sm q-mt-xl">
<q-btn
color="white"
text-color="deep-orange"
unelevated
to="/login"
:label="$t('nav.login')"
no-caps
/>
<q-btn
flat
color="white"
to="/"
:label="$t('pages.goHome')"
no-caps
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<q-page class="flex flex-center">
<div class="column items-center">
<q-btn
class="q-mt-md"
color="primary"
to="/second"
:label="$t('pages.goToSecond')"
no-caps
/>
</div>
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,97 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
<q-card-section>
<div class="text-h6">{{ t('login.title') }}</div>
</q-card-section>
<q-card-section>
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
<q-banner v-if="registered" class="bg-positive text-white" rounded>
{{ t('register.success') }}
</q-banner>
<q-input
v-model="email"
:label="t('fields.email')"
type="email"
autocomplete="email"
outlined
:rules="[(val) => !!val || t('fields.email')]"
/>
<div class="column q-gutter-xs">
<div class="text-right">
<router-link to="/password-reset" class="text-primary text-caption text-no-decoration">
{{ t('login.forgotPassword') }}
</router-link>
</div>
<q-input
v-model="password"
:label="t('fields.password')"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
outlined
:rules="[(val) => !!val || t('fields.password')]"
>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
</div>
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
{{ store.error }}
</q-banner>
<q-btn
type="submit"
color="primary"
:label="t('nav.login')"
:loading="store.loading"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/register" :label="t('login.needAccount')" />
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { useProfilesStore } from '@/stores/profiles-store';
const { t } = useI18n();
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const store = useProfilesStore();
const router = useRouter();
const route = useRoute();
const registered = route.query.registered === '1';
if (typeof route.query.email === 'string') {
email.value = route.query.email;
}
async function onSubmit() {
try {
await store.login({ user_email: email.value, password: password.value });
await router.push('/');
} catch {
// store.error already holds the failure message
}
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
<q-card-section>
<div class="text-h6">{{ pageTitle }}</div>
</q-card-section>
<q-card-section>
<q-form v-if="!token" class="column q-gutter-md" @submit.prevent="requestReset">
<q-banner v-if="requestSent" class="bg-positive text-white" rounded>
{{ t('passwordReset.requestSent') }}
</q-banner>
<q-input
v-if="!requestSent"
v-model="email"
:label="t('fields.email')"
type="email"
autocomplete="email"
outlined
:rules="[(val) => !!val || t('fields.email')]"
/>
<q-banner v-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-btn
v-if="!requestSent"
type="submit"
color="primary"
:label="t('passwordReset.sendLink')"
:loading="loading"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
</q-form>
<q-form v-else class="column q-gutter-md" @submit.prevent="confirmReset">
<q-banner v-if="resetDone" class="bg-positive text-white" rounded>
{{ t('passwordReset.resetDone') }}
</q-banner>
<q-input
v-if="!resetDone"
v-model="password"
:label="t('fields.password')"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
outlined
:rules="[(val) => !!val || t('fields.password')]"
>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<q-banner v-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-btn
v-if="!resetDone"
type="submit"
color="primary"
:label="t('passwordReset.updatePassword')"
:loading="loading"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Client, { Local } from '@/encore/client';
const { t } = useI18n();
const route = useRoute();
const client = new Client(Local);
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
const pageTitle = computed(() => (token.value ? t('passwordReset.newPasswordTitle') : t('passwordReset.title')));
const email = ref(typeof route.query.email === 'string' ? route.query.email : '');
const password = ref('');
const showPassword = ref(false);
const loading = ref(false);
const error = ref('');
const requestSent = ref(false);
const resetDone = ref(false);
async function withLoading(fn: () => Promise<void>) {
loading.value = true;
error.value = '';
try {
await fn();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function requestReset() {
await withLoading(async () => {
await client.registration.RequestPasswordReset({ email: email.value });
requestSent.value = true;
});
}
async function confirmReset() {
await withLoading(async () => {
await client.registration.ConfirmPasswordReset(token.value, { password: password.value });
resetDone.value = true;
password.value = '';
});
}
</script>

View File

@@ -0,0 +1,482 @@
<template>
<q-page class="q-pa-md">
<div class="text-h5 q-mb-md">{{ t('profile.title') }}</div>
<q-banner v-if="profilesStore.error" class="bg-negative text-white q-mb-md" rounded>
{{ profilesStore.error }}
</q-banner>
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 560px">
<q-card-section class="row items-center q-gutter-sm">
<q-avatar size="48px">
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" />
<q-icon v-else name="person" />
</q-avatar>
<div>
<div class="text-subtitle1">{{ profilesStore.profile.email }}</div>
<div class="text-subtitle1">{{ displayValue(profileForm.display_name) }}</div>
</div>
</q-card-section>
<q-tabs v-model="profileTab" align="left" class="text-primary" dense>
<q-tab v-if="isEditMode" name="avatar" :label="t('profile.avatar')" />
<q-tab name="profile" :label="t('admin.profile')" />
<q-tab name="personal" :label="t('admin.personalData')" />
<q-tab v-if="isEditMode" name="password" :label="t('profile.password')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="profileTab" animated>
<q-tab-panel v-if="isEditMode" name="avatar" class="q-gutter-md">
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
{{ profileFormErrors.avatar_url }}
</div>
</q-tab-panel>
<q-tab-panel name="profile" class="q-gutter-md">
<q-list separator>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.displayName') }}</q-item-label>
<q-item-label>{{ displayValue(profileForm.display_name) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.email') }}</q-item-label>
<q-item-label>{{ profilesStore.profile.email }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.role') }}</q-item-label>
<q-item-label>{{ profilesStore.profile.role }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.status') }}</q-item-label>
<q-item-label>
<ProfileStatusBadge :status="profilesStore.profile.status" />
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-tab-panel>
<q-tab-panel name="personal" class="q-gutter-md">
<template v-if="isEditMode">
<q-input
ref="firstNameInputRef"
v-model="personalForm.first_name"
:label="t('fields.firstName')"
:error="Boolean(personalFormErrors.first_name)"
:error-message="personalFormErrors.first_name"
/>
<q-input
v-model="personalForm.last_name"
:label="t('fields.lastName')"
:error="Boolean(personalFormErrors.last_name)"
:error-message="personalFormErrors.last_name"
/>
<q-input
v-model="personalForm.address"
:label="t('fields.address')"
:error="Boolean(personalFormErrors.address)"
:error-message="personalFormErrors.address"
/>
<q-input
v-model="personalForm.cap"
:label="t('fields.cap')"
:error="Boolean(personalFormErrors.cap)"
:error-message="personalFormErrors.cap"
/>
<q-input
v-model="personalForm.city"
:label="t('fields.city')"
:error="Boolean(personalFormErrors.city)"
:error-message="personalFormErrors.city"
/>
<q-select
v-model="personalForm.country"
:options="filteredCountries"
option-label="label"
option-value="value"
emit-value
map-options
use-input
clearable
input-debounce="200"
:label="t('fields.country')"
:error="Boolean(personalFormErrors.country)"
:error-message="personalFormErrors.country"
@filter="filterCountries"
/>
</template>
<q-list v-else separator>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.firstName') }}</q-item-label>
<q-item-label>{{ displayValue(personalForm.first_name) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.lastName') }}</q-item-label>
<q-item-label>{{ displayValue(personalForm.last_name) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.address') }}</q-item-label>
<q-item-label>{{ displayValue(personalForm.address) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.cap') }}</q-item-label>
<q-item-label>{{ displayValue(personalForm.cap) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.city') }}</q-item-label>
<q-item-label>{{ displayValue(personalForm.city) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('fields.country') }}</q-item-label>
<q-item-label>{{ displayValue(countryLabel) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-tab-panel>
<q-tab-panel v-if="isEditMode" name="password" class="q-gutter-md">
<q-input
ref="currentPasswordInputRef"
v-model="passwordForm.current_password"
:label="t('profile.currentPassword')"
:type="showCurrentPassword ? 'text' : 'password'"
autocomplete="current-password"
:error="Boolean(passwordFormErrors.current_password)"
:error-message="passwordFormErrors.current_password"
outlined
>
<template v-slot:append>
<q-icon
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showCurrentPassword = !showCurrentPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordForm.password"
:label="t('profile.newPassword')"
:type="showNewPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordFormErrors.password)"
:error-message="passwordFormErrors.password"
outlined
>
<template v-slot:append>
<q-icon
:name="showNewPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showNewPassword = !showNewPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordConfirmation"
:label="t('profile.confirmNewPassword')"
:type="showPasswordConfirmation ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordConfirmationError)"
:error-message="passwordConfirmationError"
outlined
>
<template v-slot:append>
<q-icon
:name="showPasswordConfirmation ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPasswordConfirmation = !showPasswordConfirmation"
/>
</template>
</q-input>
</q-tab-panel>
</q-tab-panels>
<q-card-actions v-if="isEditMode" align="right">
<q-btn flat :label="t('actions.cancel')" @click="cancelEdit" />
<q-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
</q-card-actions>
</q-card>
<div v-else-if="!profilesStore.isAuthenticated" class="text-grey">
{{ t('profile.loginRequired') }}
</div>
</q-page>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { countries } from '@/data/countries';
import { AuthSetPasswordParamsSchema, ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
import {
useProfilesStore,
type PersonalDataParams,
type Profile,
type ProfileParams,
type SetPasswordParams,
} from '@/stores/profiles-store';
import AvatarUpload from '@/components/AvatarUpload.vue';
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
const { t } = useI18n();
const profilesStore = useProfilesStore();
const route = useRoute();
const router = useRouter();
type CountryOption = { label: string; value: string };
type ProfileTab = 'avatar' | 'profile' | 'personal' | 'password';
type Focusable = { focus: () => void };
const countryOptions: CountryOption[] = countries.map((c) => {
const [value, label] = Object.entries(c)[0] as [string, string];
return { label, value };
});
const filteredCountries = ref<CountryOption[]>(countryOptions);
const profileTab = ref<ProfileTab>('profile');
const isEditMode = computed(() => route.query.mode === 'edit');
const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country);
const firstNameInputRef = ref<Focusable | null>(null);
const currentPasswordInputRef = ref<Focusable | null>(null);
const profileForm = reactive<ProfileParams>({
display_name: '',
avatar_url: '',
});
const profileFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
const personalForm = reactive<PersonalDataParams>({
first_name: '',
last_name: '',
address: '',
cap: '',
city: '',
country: '',
});
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
const passwordForm = reactive<SetPasswordParams>({
current_password: '',
password: '',
});
const passwordFormErrors = reactive<Partial<Record<keyof SetPasswordParams, string>>>({});
const passwordConfirmation = ref('');
const passwordConfirmationError = ref('');
const showCurrentPassword = ref(false);
const showNewPassword = ref(false);
const showPasswordConfirmation = ref(false);
watch(
() => profilesStore.profile,
(profile) => {
if (profile) {
void loadProfile(profile);
}
},
{ immediate: true },
);
watch(profileTab, () => {
if (isEditMode.value) {
void focusFirstField();
}
});
watch(isEditMode, (editing) => {
if (editing) {
profileTab.value = 'avatar';
resetPasswordForm();
void focusFirstField();
} else if (profilesStore.profile) {
profileTab.value = profileTab.value === 'avatar' || profileTab.value === 'password' ? 'profile' : profileTab.value;
void loadProfile(profilesStore.profile);
}
});
function filterCountries(val: string, update: (cb: () => void) => void) {
update(() => {
const needle = val.toLowerCase();
filteredCountries.value = needle
? countryOptions.filter(
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
)
: countryOptions;
});
}
async function loadProfile(profile: Profile) {
profileForm.display_name = profile.display_name;
profileForm.avatar_url = profile.avatar_url;
clearProfileFormErrors();
resetPersonalForm();
try {
const data = profilesStore.personalData ?? (await profilesStore.fetchPersonalData());
resetPersonalForm(data);
} catch {
// No personal data yet (404) - leave the form empty for creation.
profilesStore.error = null;
}
}
async function save() {
try {
if (profileTab.value === 'avatar') {
const parsed = validateProfileForm();
if (!parsed) return;
await profilesStore.update(parsed);
} else if (profileTab.value === 'personal') {
const parsed = validatePersonalForm();
if (!parsed) return;
await profilesStore.savePersonalData(parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
} else if (profileTab.value === 'password') {
const parsed = validatePasswordForm();
if (!parsed) return;
await profilesStore.setPassword(parsed);
resetPasswordForm();
}
await router.replace('/profile');
} catch {
// profilesStore.error already holds the failure message
}
}
async function cancelEdit() {
if (profilesStore.profile) {
await loadProfile(profilesStore.profile);
}
await router.replace('/profile');
}
function resetPersonalForm(data?: PersonalDataParams) {
personalForm.first_name = data?.first_name ?? '';
personalForm.last_name = data?.last_name ?? '';
personalForm.address = data?.address ?? '';
personalForm.cap = data?.cap ?? '';
personalForm.city = data?.city ?? '';
personalForm.country = data?.country ?? '';
clearPersonalFormErrors();
}
function validateProfileForm(): ProfileParams | null {
clearProfileFormErrors();
const result = ProfilesProfileParamsSchema.safeParse({ ...profileForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
profileFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearProfileFormErrors() {
profileFormErrors.display_name = '';
profileFormErrors.avatar_url = '';
}
function validatePersonalForm(): PersonalDataParams | null {
clearPersonalFormErrors();
const result = ProfilesPersonalDataParamsSchema.safeParse({ ...personalForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearPersonalFormErrors() {
personalFormErrors.first_name = '';
personalFormErrors.last_name = '';
personalFormErrors.address = '';
personalFormErrors.cap = '';
personalFormErrors.city = '';
personalFormErrors.country = '';
}
function validatePasswordForm(): SetPasswordParams | null {
clearPasswordFormErrors();
const result = AuthSetPasswordParamsSchema.safeParse({ ...passwordForm });
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof SetPasswordParams)[]) {
passwordFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
if (passwordForm.password !== passwordConfirmation.value) {
passwordConfirmationError.value = t('profile.passwordMismatch');
return null;
}
return result.data;
}
function clearPasswordFormErrors() {
passwordFormErrors.current_password = '';
passwordFormErrors.password = '';
passwordConfirmationError.value = '';
}
function resetPasswordForm() {
passwordForm.current_password = '';
passwordForm.password = '';
passwordConfirmation.value = '';
showCurrentPassword.value = false;
showNewPassword.value = false;
showPasswordConfirmation.value = false;
clearPasswordFormErrors();
}
function displayValue(value: string | number | null | undefined) {
const normalized = String(value ?? '').trim();
return normalized || '-';
}
async function focusFirstField() {
await nextTick();
if (profileTab.value === 'personal') {
firstNameInputRef.value?.focus();
} else if (profileTab.value === 'password') {
currentPasswordInputRef.value?.focus();
}
}
onMounted(async () => {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
try {
await profilesStore.me();
} catch {
// profilesStore.error already holds the failure message
}
}
});
</script>

View File

@@ -0,0 +1,170 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
<template v-if="success">
<q-card-section>
<div class="text-h6">{{ t('register.welcomeTitle') }}</div>
</q-card-section>
<q-card-section>
<q-banner class="bg-positive text-white" rounded>
{{ t('register.confirmationSent') }}
</q-banner>
</q-card-section>
</template>
<template v-else>
<q-card-section>
<div class="text-h6">{{ t('register.title') }}</div>
</q-card-section>
<q-card-section>
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
<q-input
ref="emailInputRef"
v-model="form.email"
:label="t('fields.email')"
type="email"
autocomplete="email"
:error="Boolean(fieldErrors.email)"
:error-message="fieldErrors.email"
:loading="checkingEmail"
@blur="checkEmailAvailability"
/>
<q-input
v-model="form.password"
:label="t('fields.password')"
type="password"
autocomplete="new-password"
:error="Boolean(fieldErrors.password)"
:error-message="fieldErrors.password"
/>
<q-input
v-model="form.display_name"
:label="t('fields.displayName')"
:error="Boolean(fieldErrors.display_name)"
:error-message="fieldErrors.display_name"
/>
<q-banner v-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-btn
type="submit"
color="primary"
:label="t('actions.register')"
:loading="loading || checkingEmail"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/login" :label="t('register.haveAccount')" />
</q-form>
</q-card-section>
</template>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Client, { Local, type registration } from '@/encore/client';
import { RegistrationRegisterParamsSchema } from '@/encore/zod';
type Focusable = { focus: () => void };
const { t } = useI18n();
const client = new Client(Local);
const emailInputRef = ref<Focusable | null>(null);
const loading = ref(false);
const checkingEmail = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const fieldErrors = reactive<Partial<Record<keyof registration.RegisterParams, string>>>({});
const form = reactive<registration.RegisterParams>({
email: '',
password: '',
display_name: '',
avatar_url: '',
});
onMounted(async () => {
await nextTick();
emailInputRef.value?.focus();
});
async function onSubmit() {
const params = validateForm();
if (!params) return;
const emailAvailable = await checkEmailAvailability();
if (!emailAvailable) return;
loading.value = true;
error.value = null;
success.value = false;
try {
await client.registration.Register(params);
success.value = true;
resetForm();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function checkEmailAvailability(): Promise<boolean> {
const emailResult = RegistrationRegisterParamsSchema.shape.email.safeParse(form.email);
if (!emailResult.success) return false;
checkingEmail.value = true;
fieldErrors.email = '';
try {
const res = await client.registration.CheckEmail(form.email);
if (!res.mx_valid) {
fieldErrors.email = t('register.emailDomainInvalid');
return false;
}
if (!res.available) {
fieldErrors.email = t('register.emailUnavailable');
return false;
}
return true;
} catch (err) {
fieldErrors.email = err instanceof Error ? err.message : String(err);
return false;
} finally {
checkingEmail.value = false;
}
}
function validateForm(): registration.RegisterParams | null {
clearFieldErrors();
const result = RegistrationRegisterParamsSchema.safeParse({ ...form });
if (result.success) return result.data;
const flattened = result.error.flatten().fieldErrors;
for (const key of Object.keys(flattened) as (keyof registration.RegisterParams)[]) {
fieldErrors[key] = flattened[key]?.[0] ?? '';
}
return null;
}
function clearFieldErrors() {
fieldErrors.email = '';
fieldErrors.password = '';
fieldErrors.display_name = '';
fieldErrors.avatar_url = '';
}
function resetForm() {
form.email = '';
form.password = '';
form.display_name = '';
form.avatar_url = '';
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<q-page class="flex flex-center">
<q-btn color="secondary" to="/" :label="$t('pages.goToIndex')" no-caps />
</q-page>
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,60 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
<q-card-section>
<div class="text-h6">{{ t('welcome.title') }}</div>
</q-card-section>
<q-card-section>
<q-inner-loading :showing="loading" />
<q-banner v-if="success" class="bg-positive text-white" rounded>
{{ t('welcome.success', { email }) }}
</q-banner>
<q-banner v-else-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-banner v-else-if="!loading" class="bg-warning text-white" rounded>
{{ t('welcome.missingToken') }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn color="primary" to="/login" :label="t('nav.login')" no-caps />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Client, { Local } from '@/encore/client';
const { t } = useI18n();
const route = useRoute();
const client = new Client(Local);
const loading = ref(false);
const success = ref(false);
const error = ref('');
const email = ref('');
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) return;
loading.value = true;
try {
const res = await client.registration.ConfirmWelcome(token);
success.value = res.confirmed;
email.value = res.email;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
});
</script>

View File

@@ -0,0 +1,11 @@
<template>
<q-page class="q-pa-md">
<div class="text-h5">{{ t('admin.dashboard') }}</div>
</q-page>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,237 @@
<template>
<q-page class="q-pa-md" ref="profilesPage">
<div class="row items-center q-mb-md">
<div class="text-h5">{{ t('admin.profiles') }}</div>
<q-space />
<q-btn color="primary" icon="add" :label="t('actions.newProfile')" @click="openCreate" />
</div>
<q-banner v-if="adminStore.error" class="bg-negative text-white q-mb-md" rounded>
{{ adminStore.error }}
</q-banner>
<q-table
:style="{ height: `${profilesTableHeight}px` }"
class="sticky-header-table"
:rows="adminStore.profiles"
:columns="columns"
row-key="user_id"
:loading="adminStore.loading"
flat
bordered
ref="profilesTable"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
</q-td>
</template>
<template v-slot:body-cell-is_artist="props">
<q-td :props="props">
<q-icon
:name="props.value ? 'check_circle' : 'cancel'"
:color="props.value ? 'positive' : 'grey-5'"
size="sm"
>
<q-tooltip>{{ props.value ? t('admin.artist') : t('admin.notArtist') }}</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-avatar_url="props">
<q-td :props="props" auto-width>
<q-avatar size="32px">
<img v-if="props.value" :src="props.value" />
<q-icon v-else name="person" />
</q-avatar>
<q-btn flat round dense icon="more_vert" size="sm" class="q-ml-xs">
<q-menu>
<q-list>
<q-item v-close-popup clickable @click="openEdit(props.row)">
<q-item-section>{{ t('actions.editProfile') }}</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="openRoleEdit(props.row)">
<q-item-section>{{ t('actions.updateRole') }}</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="openPasswordEdit(props.row)">
<q-item-section>{{ t('actions.updatePassword') }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
<UpdatePasswordDialog v-model="passwordDialogOpen" :profile="passwordEditingProfile" />
<CreateProfileDialog v-model="createDialogOpen" />
</q-page>
</template>
<style lang="scss">
.sticky-header-table { /* height or max-height is important */
.q-table__top,
.q-table__bottom,
thead tr:first-child th { /* bg color is important for th; just specify one */
background-color: #ffffff;
}
thead tr th {
position: sticky;
z-index: 1;
}
thead tr:first-child th {
top: 0;
}
/* this is when the loading indicator appears */
&.q-table--loading thead tr:last-child th { /* height of all previous header rows */
top: 48px;
}
/* prevent scrolling behind sticky top row on focus */
tbody { /* height of all previous header rows */
scroll-margin-top: 48px;
}
}</style>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import type { QTableColumn } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
import { useLayoutStore } from '@/stores/layout-store';
import { statusInfo } from '@/stores/profiles-store';
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
import UpdatePasswordDialog from './dialogs/UpdatePasswordDialog.vue';
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
const adminStore = useAdminStore();
const layoutStore = useLayoutStore();
const { t } = useI18n();
const profilesPage = ref<ComponentPublicInstance | null>(null);
const profilesTable = ref<ComponentPublicInstance | null>(null);
const profilesTableHeight = ref(610);
const editDialogOpen = ref(false);
const editingProfile = ref<Profile | null>(null);
function openEdit(profile: Profile) {
editingProfile.value = profile;
editDialogOpen.value = true;
}
const createDialogOpen = ref(false);
function openCreate() {
createDialogOpen.value = true;
}
const roleDialogOpen = ref(false);
const roleEditingProfile = ref<Profile | null>(null);
function openRoleEdit(profile: Profile) {
roleEditingProfile.value = profile;
roleDialogOpen.value = true;
}
const statusDialogOpen = ref(false);
const statusEditingProfile = ref<Profile | null>(null);
function openStatusEdit(profile: Profile) {
statusEditingProfile.value = profile;
statusDialogOpen.value = true;
}
const passwordDialogOpen = ref(false);
const passwordEditingProfile = ref<Profile | null>(null);
function openPasswordEdit(profile: Profile) {
passwordEditingProfile.value = profile;
passwordDialogOpen.value = true;
}
const columns = computed<QTableColumn[]>(() => [
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
{ name: 'email', label: t('admin.columns.email'), field: 'email', align: 'left', sortable: true },
{ name: 'role', label: t('admin.columns.role'), field: 'role', align: 'left', sortable: true },
{ name: 'status', label: t('admin.columns.status'), field: 'status', align: 'left', sortable: true },
{ name: 'is_artist', label: t('admin.columns.artist'), field: 'is_artist', align: 'center', sortable: true },
{
name: 'created_at',
label: t('admin.columns.created'),
field: 'created_at',
align: 'left',
sortable: true,
format: (val: string) => new Date(val).toLocaleString(),
},
{
name: 'updated_at',
label: t('admin.columns.updated'),
field: 'updated_at',
align: 'left',
sortable: true,
format: (val: string) => new Date(val).toLocaleString(),
},
]);
function statusLabel(status: number) {
const key = `status.${status}`;
const translated = t(key);
return translated === key ? t('status.unknown', { status }) : translated;
}
function getElement(component: ComponentPublicInstance | null) {
return component?.$el instanceof HTMLElement ? component.$el : null;
}
function updateProfilesTableHeight() {
const page = getElement(profilesPage.value);
const table = getElement(profilesTable.value);
if (!page || !table) {
return;
}
const tableRect = table.getBoundingClientRect();
const pageStyle = window.getComputedStyle(page);
const pagePaddingBottom = parseFloat(pageStyle.paddingBottom) || 0;
const availableHeight = window.innerHeight - tableRect.top - pagePaddingBottom;
profilesTableHeight.value = Math.max(0, Math.floor(availableHeight));
}
async function updateProfilesTableHeightAfterRender() {
await nextTick();
updateProfilesTableHeight();
}
onMounted(async () => {
void adminStore.listProfiles();
void adminStore.fetchSystemOptions();
layoutStore.startBodyResizeListener();
await updateProfilesTableHeightAfterRender();
});
watch(
() => [adminStore.error, layoutStore.bodyWidth, layoutStore.bodyHeight],
() => {
void updateProfilesTableHeightAfterRender();
},
);
onBeforeUnmount(() => {
layoutStore.stopBodyResizeListener();
});
</script>

View File

@@ -0,0 +1,88 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.newProfile') }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input
ref="emailInputRef"
v-model="createForm.email"
:label="t('fields.email')"
type="email"
autocomplete="off"
name="new-profile-email"
/>
<q-input
v-model="createForm.password"
:label="t('fields.password')"
type="password"
autocomplete="new-password"
name="new-profile-password"
/>
<q-input v-model="createForm.display_name" :label="t('fields.displayName')" />
<AvatarUpload v-model="createForm.avatar_url" :uploader="adminStore.uploadAvatar" />
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.create')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
import AvatarUpload from '@/components/AvatarUpload.vue';
type Focusable = { focus: () => void };
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const emailInputRef = ref<Focusable | null>(null);
const createForm = reactive<RegisterParams>({
email: '',
password: '',
display_name: '',
avatar_url: '',
});
watch(open, (isOpen) => {
if (isOpen) {
resetForm();
}
});
async function save() {
try {
await adminStore.insertProfile({ ...createForm });
Notify.create({ type: 'positive', message: t('admin.notifications.profileCreated') });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
function resetForm() {
createForm.email = '';
createForm.password = '';
createForm.display_name = '';
createForm.avatar_url = '';
}
async function focusFirstField() {
await nextTick();
emailInputRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,261 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section v-if="profile" class="row items-center q-gutter-sm">
<q-avatar size="48px">
<img v-if="editForm.avatar_url" :src="editForm.avatar_url" />
<q-icon v-else name="person" />
</q-avatar>
<div>
<div class="text-subtitle1">{{ profile.email }}</div>
<div class="text-caption text-grey">{{ profile.role }} · {{ profile.user_id }}</div>
</div>
</q-card-section>
<q-tabs v-model="editTab" align="left" class="text-primary" dense>
<q-tab name="profile" :label="t('admin.profile')" />
<q-tab name="personal" :label="t('admin.personalData')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="editTab" animated>
<q-tab-panel name="profile" class="q-gutter-md">
<AvatarUpload v-model="editForm.avatar_url" :uploader="adminStore.uploadAvatar" />
<div v-if="editFormErrors.avatar_url" class="text-negative text-caption">
{{ editFormErrors.avatar_url }}
</div>
<q-input
ref="displayNameInputRef"
v-model="editForm.display_name"
:label="t('fields.displayName')"
:error="Boolean(editFormErrors.display_name)"
:error-message="editFormErrors.display_name"
/>
</q-tab-panel>
<q-tab-panel name="personal" class="q-gutter-md">
<q-input
ref="firstNameInputRef"
v-model="personalForm.first_name"
:label="t('fields.firstName')"
:error="Boolean(personalFormErrors.first_name)"
:error-message="personalFormErrors.first_name"
/>
<q-input
v-model="personalForm.last_name"
:label="t('fields.lastName')"
:error="Boolean(personalFormErrors.last_name)"
:error-message="personalFormErrors.last_name"
/>
<q-input
v-model="personalForm.address"
:label="t('fields.address')"
:error="Boolean(personalFormErrors.address)"
:error-message="personalFormErrors.address"
/>
<q-input
v-model="personalForm.cap"
:label="t('fields.cap')"
:error="Boolean(personalFormErrors.cap)"
:error-message="personalFormErrors.cap"
/>
<q-input
v-model="personalForm.city"
:label="t('fields.city')"
:error="Boolean(personalFormErrors.city)"
:error-message="personalFormErrors.city"
/>
<q-select
v-model="personalForm.country"
:options="filteredCountries"
option-label="label"
option-value="value"
emit-value
map-options
use-input
clearable
input-debounce="200"
:label="t('fields.country')"
:error="Boolean(personalFormErrors.country)"
:error-message="personalFormErrors.country"
@filter="filterCountries"
/>
</q-tab-panel>
</q-tab-panels>
<q-card-section v-if="adminStore.error">
<q-banner class="bg-negative text-white" rounded>{{ adminStore.error }}</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
import { countries } from '@/data/countries';
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
import AvatarUpload from '@/components/AvatarUpload.vue';
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
type CountryOption = { label: string; value: string };
type EditTab = 'profile' | 'personal';
type Focusable = { focus: () => void };
const countryOptions: CountryOption[] = countries.map((c) => {
const [value, label] = Object.entries(c)[0] as [string, string];
return { label, value };
});
const filteredCountries = ref<CountryOption[]>(countryOptions);
const editTab = ref<EditTab>('profile');
const displayNameInputRef = ref<Focusable | null>(null);
const firstNameInputRef = ref<Focusable | null>(null);
const editForm = reactive<ProfileParams>({
display_name: '',
avatar_url: '',
});
const editFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
const personalForm = reactive<PersonalDataParams>({
first_name: '',
last_name: '',
address: '',
cap: '',
city: '',
country: '',
});
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
void loadProfile(profile);
}
},
{ immediate: true },
);
watch(editTab, () => {
if (open.value) {
void focusFirstField();
}
});
function filterCountries(val: string, update: (cb: () => void) => void) {
update(() => {
const needle = val.toLowerCase();
filteredCountries.value = needle
? countryOptions.filter(
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
)
: countryOptions;
});
}
async function loadProfile(profile: Profile) {
editTab.value = 'profile';
editForm.display_name = profile.display_name;
editForm.avatar_url = profile.avatar_url;
clearEditFormErrors();
resetPersonalForm();
try {
const data = await adminStore.getPersonalData(profile.user_id);
resetPersonalForm(data);
} catch {
// No personal data yet (404) — leave the form empty for creation.
adminStore.error = null;
}
}
async function save() {
if (!props.profile) return;
try {
if (editTab.value === 'profile') {
const parsed = validateEditForm();
if (!parsed) return;
await adminStore.updateProfile(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.profileUpdated') });
} else {
const parsed = validatePersonalForm();
if (!parsed) return;
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
}
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
function resetPersonalForm(data?: PersonalDataParams) {
personalForm.first_name = data?.first_name ?? '';
personalForm.last_name = data?.last_name ?? '';
personalForm.address = data?.address ?? '';
personalForm.cap = data?.cap ?? '';
personalForm.city = data?.city ?? '';
personalForm.country = data?.country ?? '';
clearPersonalFormErrors();
}
function validateEditForm(): ProfileParams | null {
clearEditFormErrors();
const result = AdminProfileParamsSchema.safeParse({ ...editForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
editFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearEditFormErrors() {
editFormErrors.display_name = '';
editFormErrors.avatar_url = '';
}
function validatePersonalForm(): PersonalDataParams | null {
clearPersonalFormErrors();
const result = AdminPersonalDataParamsSchema.safeParse({ ...personalForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearPersonalFormErrors() {
personalFormErrors.first_name = '';
personalFormErrors.last_name = '';
personalFormErrors.address = '';
personalFormErrors.cap = '';
personalFormErrors.city = '';
personalFormErrors.country = '';
}
async function focusFirstField() {
await nextTick();
if (editTab.value === 'profile') {
displayNameInputRef.value?.focus();
} else {
firstNameInputRef.value?.focus();
}
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updatePassword') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-input
ref="passwordInputRef"
v-model="password"
:label="t('fields.password')"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordError)"
:error-message="passwordError"
outlined
>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const password = ref('');
const passwordError = ref('');
const showPassword = ref(false);
const passwordInputRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen]) => {
if (isOpen) {
password.value = '';
passwordError.value = '';
showPassword.value = false;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
passwordError.value = '';
if (!password.value) {
passwordError.value = t('fields.password');
return;
}
try {
await adminStore.updateProfilePassword(props.profile.user_id, { password: password.value });
Notify.create({ type: 'positive', message: t('admin.notifications.passwordUpdated') });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
passwordInputRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updateRole') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-select
ref="roleSelectRef"
v-model="selectedRole"
:options="adminStore.roles"
option-label="name"
option-value="value"
emit-value
map-options
:label="t('fields.role')"
/>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const selectedRole = ref('');
const roleSelectRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
selectedRole.value = profile.role;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
try {
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
Notify.create({ type: 'positive', message: t('admin.notifications.roleUpdated') });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
roleSelectRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updateStatus') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-select
ref="statusSelectRef"
v-model="selectedStatus"
:options="adminStore.updatableStatuses"
option-label="name"
option-value="value"
emit-value
map-options
:label="t('fields.status')"
/>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
import type { Status } from '@/stores/profiles-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const selectedStatus = ref<Status>(0);
const statusSelectRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
selectedStatus.value = profile.status;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
try {
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
Notify.create({ type: 'positive', message: t('admin.notifications.statusUpdated') });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
statusSelectRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,36 @@
import { defineRouter } from '#q-app';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter((/* { store, ssrContext } */) => {
const createHistory = import.meta.env.QUASAR_SERVER
? createMemoryHistory
: (import.meta.env.QUASAR_VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(import.meta.env.QUASAR_VUE_ROUTER_BASE)
});
return Router;
});

View File

@@ -0,0 +1,34 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('@/pages/IndexPage.vue') },
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
{ path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') },
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
{ path: '401', component: () => import('@/pages/ErrorUnauthorized.vue') },
],
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{ path: 'dashboard', component: () => import('@/pages/admin/DashboardPage.vue') },
{ path: 'profiles', component: () => import('@/pages/admin/ProfilesPage.vue') }
],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('@/pages/ErrorNotFound.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,159 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import Client, { Local, admin as AdminNS, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
import { useProfilesStore } from '@/stores/profiles-store';
export type Profile = AdminNS.Profile;
export type ProfileParams = AdminNS.ProfileParams;
export type RegisterParams = AdminNS.RegisterParams;
export type PersonalData = AdminNS.PersonalData;
export type PersonalDataParams = AdminNS.PersonalDataParams;
export type UpdateProfilePasswordParams = AdminNS.UpdateProfilePasswordParams;
export type RoleOption = AuthNS.RoleOption;
export type StatusOption = ProfilesNS.StatusOption;
export const useAdminStore = defineStore('admin', () => {
const profiles = ref<Profile[]>([]);
const roles = ref<RoleOption[]>([]);
const statuses = ref<StatusOption[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const profilesStore = useProfilesStore();
const client = new Client(Local, {
auth: () => (profilesStore.token ? { Authorization: `Bearer ${profilesStore.token}` } : undefined),
});
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true;
error.value = null;
try {
return await fn();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
/** Fetches all profiles into `profiles`. Requires the caller to be logged in as an admin. */
async function listProfiles(): Promise<Profile[]> {
return withLoading(async () => {
const res = await client.admin.ListProfiles();
profiles.value = res.profiles;
return res.profiles;
});
}
/** Creates a new user profile with credentials. Requires the caller to be logged in as an admin. */
async function insertProfile(params: RegisterParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.InsertProfile(params);
profiles.value.push(res);
return res;
});
}
/** Fetches the valid roles and profile statuses into `roles` and `statuses`. */
async function fetchSystemOptions(): Promise<void> {
return withLoading(async () => {
const res = await client.admin.GetSystemOptions();
roles.value = res.roles;
statuses.value = res.statuses;
});
}
/** Statuses that an admin can manually set on a profile. */
const updatableStatuses = computed(() => statuses.value.filter((s) => s.updatable));
function replaceProfile(updated: Profile) {
const idx = profiles.value.findIndex((p) => p.user_id === updated.user_id);
if (idx !== -1) {
profiles.value[idx] = updated;
}
}
/** Updates any user's profile. Requires the caller to be logged in as an admin. */
async function updateProfile(userID: string, params: ProfileParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfile(userID, params);
replaceProfile(res);
return res;
});
}
/** Updates the role of any user's profile. */
async function updateProfileRole(userID: string, role: string): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfileRole(userID, { role });
replaceProfile(res);
return res;
});
}
/** Updates the status of any user's profile. */
async function updateProfileStatus(userID: string, status: ProfilesNS.Status): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfileStatus(userID, { status });
replaceProfile(res);
return res;
});
}
/** Updates the password of any user's profile. */
async function updateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
return withLoading(async () => {
await client.admin.UpdateProfilePassword(userID, params);
});
}
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
async function getPersonalData(userID: string): Promise<PersonalData> {
return withLoading(async () => {
return await client.admin.GetPersonalData(userID);
});
}
/** Creates or replaces the personal data of any user. */
async function upsertPersonalData(userID: string, params: PersonalDataParams): Promise<PersonalData> {
return withLoading(async () => {
return await client.admin.UpsertPersonalData(userID, params);
});
}
/** Uploads an avatar image and returns its public URL. */
async function uploadAvatar(image: Blob): Promise<string> {
return withLoading(async () => {
const resp = await client.profiles.UploadAvatar('POST', image);
if (!resp.ok) {
throw new Error(`avatar upload failed (${resp.status})`);
}
const data = (await resp.json()) as { url: string };
return data.url;
});
}
return {
// state
profiles,
roles,
statuses,
loading,
error,
// getters
updatableStatuses,
// actions
listProfiles,
fetchSystemOptions,
insertProfile,
updateProfile,
updateProfileRole,
updateProfileStatus,
updateProfilePassword,
getPersonalData,
upsertPersonalData,
uploadAvatar,
};
});

View File

@@ -0,0 +1,32 @@
import { defineStore } from '#q-app';
import { createPinia } from 'pinia';
/*
* When adding new properties to stores, you should also
* extend the `PiniaCustomProperties` interface.
* @see https://pinia.vuejs.org/core-concepts/plugins.html#Typing-new-store-properties
*/
declare module 'pinia' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PiniaCustomProperties {
// add your custom properties here, if any
}
}
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) => {
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});

View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useLayoutStore = defineStore('layout', () => {
const bodyWidth = ref(0);
const bodyHeight = ref(0);
let bodyResizeObserver: ResizeObserver | null = null;
let listeners = 0;
function updateBodySize() {
if (typeof document === 'undefined') {
return;
}
const rect = document.body.getBoundingClientRect();
bodyWidth.value = Math.floor(rect.width);
bodyHeight.value = Math.floor(rect.height);
}
function startBodyResizeListener() {
listeners += 1;
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
updateBodySize();
if (bodyResizeObserver) {
return;
}
bodyResizeObserver = new ResizeObserver(() => {
updateBodySize();
});
bodyResizeObserver.observe(document.body);
window.addEventListener('resize', updateBodySize);
}
function stopBodyResizeListener() {
listeners = Math.max(0, listeners - 1);
if (listeners > 0) {
return;
}
bodyResizeObserver?.disconnect();
bodyResizeObserver = null;
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateBodySize);
}
}
return {
bodyWidth,
bodyHeight,
startBodyResizeListener,
stopBodyResizeListener,
};
});

View File

@@ -0,0 +1,176 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { LocalStorage } from 'quasar';
import Client, { Local, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
export type Profile = ProfilesNS.Profile;
export type ProfileParams = ProfilesNS.ProfileParams;
export type RegisterParams = ProfilesNS.RegisterParams;
export type LoginParams = ProfilesNS.LoginParams;
export type LoginResponse = ProfilesNS.LoginResponse;
export type Status = ProfilesNS.Status;
export type PersonalData = ProfilesNS.PersonalData;
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
export type SetPasswordParams = AuthNS.SetPasswordParams;
/** Display info for each Status value, mirroring profiles/status.go. */
export const STATUS_INFO: { label: string; color: string }[] = [
{ label: 'Active', color: 'positive' },
{ label: 'Inactive', color: 'grey' },
{ label: 'Suspended', color: 'warning' },
{ label: 'Deleted', color: 'negative' },
{ label: 'Pending', color: 'info' },
{ label: 'Waiting deletion', color: 'warning' },
{ label: 'Banned', color: 'negative' },
];
export function statusInfo(status: Status): { label: string; color: string } {
return STATUS_INFO[status] ?? { label: `Unknown (${status})`, color: 'grey' };
}
const TOKEN_STORAGE_KEY = 'profiles.token';
export const useProfilesStore = defineStore('profiles', () => {
const token = ref<string | null>(LocalStorage.getItem(TOKEN_STORAGE_KEY));
const profile = ref<Profile | null>(null);
const personalData = ref<PersonalData | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const isAuthenticated = computed(() => token.value !== null);
const client = new Client(Local, {
auth: () => (token.value ? { Authorization: `Bearer ${token.value}` } : undefined),
});
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true;
error.value = null;
try {
return await fn();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
function setToken(newToken: string | null) {
token.value = newToken;
if (newToken) {
LocalStorage.set(TOKEN_STORAGE_KEY, newToken);
} else {
LocalStorage.remove(TOKEN_STORAGE_KEY);
}
}
/** Logs in and fetches the resulting profile into `profile`. */
async function login(params: LoginParams): Promise<LoginResponse> {
return withLoading(async () => {
const res = await client.profiles.Login(params);
setToken(res.token);
await me();
return res;
});
}
/** Clears the local session. Does not call the auth service's Logout endpoint. */
function logout() {
setToken(null);
profile.value = null;
personalData.value = null;
}
/** Fetches the authenticated caller's profile, or null if not authenticated. */
async function me(): Promise<Profile | null> {
return withLoading(async () => {
const res = await client.profiles.Me();
profile.value = res;
return res;
});
}
/** Registers a new profile (and credentials) and returns it. */
async function register(params: RegisterParams): Promise<Profile> {
return withLoading(async () => {
return await client.profiles.Insert(params);
});
}
/** Updates the authenticated caller's own profile. */
async function update(params: ProfileParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.profiles.Update(params);
profile.value = res;
return res;
});
}
/** Deletes the authenticated caller's own profile and clears the session. */
async function remove(): Promise<void> {
return withLoading(async () => {
await client.profiles.Delete();
logout();
});
}
/** Fetches the authenticated caller's own personal data into `personalData`. */
async function fetchPersonalData(): Promise<PersonalData> {
return withLoading(async () => {
const res = await client.profiles.GetPersonalData();
personalData.value = res;
return res;
});
}
/** Creates or replaces the authenticated caller's own personal data. */
async function savePersonalData(params: PersonalDataParams): Promise<PersonalData> {
return withLoading(async () => {
const res = await client.profiles.UpsertPersonalData(params);
personalData.value = res;
return res;
});
}
/** Changes the authenticated caller's password after verifying the current one. */
async function setPassword(params: SetPasswordParams): Promise<void> {
return withLoading(async () => {
await client.auth.SetPassword(params);
});
}
/** Uploads an avatar image and returns its public URL. */
async function uploadAvatar(image: Blob): Promise<string> {
return withLoading(async () => {
const resp = await client.profiles.UploadAvatar('POST', image);
if (!resp.ok) {
throw new Error(`avatar upload failed (${resp.status})`);
}
const data = (await resp.json()) as { url: string };
return data.url;
});
}
return {
// state
token,
profile,
personalData,
loading,
error,
// getters
isAuthenticated,
// actions
login,
logout,
me,
register,
update,
remove,
fetchPersonalData,
savePersonalData,
setPassword,
uploadAvatar,
};
});

327
frontend/tools/zod-sync.mjs Normal file
View File

@@ -0,0 +1,327 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const rootDir = process.cwd();
const clientPath = path.join(rootDir, 'src/encore/client.ts');
const outputPath = path.join(rootDir, 'src/encore/zod.ts');
const clientSource = fs.readFileSync(clientPath, 'utf8');
const clientFile = ts.createSourceFile(clientPath, clientSource, ts.ScriptTarget.Latest, true);
const existingSource = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
const existingFile = ts.createSourceFile(outputPath, existingSource, ts.ScriptTarget.Latest, true);
const schemas = collectEncoreSchemas(clientFile);
const existingSchemas = collectExistingSchemas(existingFile, existingSource);
const generated = renderOutput(schemas, existingSchemas);
fs.writeFileSync(outputPath, generated);
console.log(`Synced ${schemas.length} Zod schemas to ${path.relative(rootDir, outputPath)}`);
function collectEncoreSchemas(sourceFile) {
const found = [];
const typeAliases = new Map();
const writableParamTypes = collectWritableParamTypes(sourceFile);
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
typeAliases.set(`${namespaceName}.${child.name.text}`, child.type);
}
}
}
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
const qualifiedName = `${namespaceName}.${child.name?.text ?? ''}`;
if (!writableParamTypes.has(qualifiedName)) continue;
if (ts.isInterfaceDeclaration(child) && isExported(child)) {
found.push({
kind: 'object',
namespaceName,
typeName: child.name.text,
schemaName: schemaName(namespaceName, child.name.text),
fields: child.members
.filter(ts.isPropertySignature)
.map((member) => ({
name: propertyName(member.name),
zod: zodForType(member.type, namespaceName, typeAliases),
}))
.filter((field) => field.name),
});
}
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
found.push({
kind: 'alias',
namespaceName,
typeName: child.name.text,
schemaName: schemaName(namespaceName, child.name.text),
zod: zodForType(child.type, namespaceName, typeAliases),
});
}
}
}
return found;
}
function collectWritableParamTypes(sourceFile) {
const found = new Set();
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
if (!ts.isClassDeclaration(child) || child.name?.text !== 'ServiceClient') continue;
for (const member of child.members) {
if (!ts.isMethodDeclaration(member)) continue;
const httpMethod = writableHttpMethod(member);
if (!httpMethod) continue;
const bodyParamName = jsonStringifiedParamName(member);
if (!bodyParamName) continue;
const param = member.parameters.find((candidate) => propertyName(candidate.name) === bodyParamName);
if (!param?.type || !ts.isTypeReferenceNode(param.type)) continue;
const ref = typeNameText(param.type.typeName);
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
found.add(qualifiedRef);
}
}
}
return found;
}
function writableHttpMethod(method) {
let result = '';
visit(method.body);
return result;
function visit(node) {
if (result || !node) return;
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const callName = node.expression.name.text;
if (callName === 'callTypedAPI' || callName === 'callAPI') {
const [methodArg] = node.arguments;
if (methodArg && ts.isStringLiteral(methodArg) && ['POST', 'PUT'].includes(methodArg.text)) {
result = methodArg.text;
return;
}
}
}
ts.forEachChild(node, visit);
}
}
function jsonStringifiedParamName(method) {
let result = '';
visit(method.body);
return result;
function visit(node) {
if (result || !node) return;
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'JSON' &&
node.expression.name.text === 'stringify'
) {
const [arg] = node.arguments;
if (arg && ts.isIdentifier(arg)) {
result = arg.text;
return;
}
}
ts.forEachChild(node, visit);
}
}
function collectExistingSchemas(sourceFile, sourceText) {
const result = new Map();
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
if (!declaration.name.text.endsWith('Schema')) continue;
const objectLiteral = zodObjectLiteral(declaration.initializer);
if (!objectLiteral) continue;
const fields = new Map();
for (const prop of objectLiteral.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = propertyName(prop.name);
if (!name) continue;
fields.set(name, prop.initializer.getText(sourceFile));
}
result.set(declaration.name.text, { fields });
}
}
if (!sourceText.trim()) return result;
return result;
}
function renderOutput(schemas, existingSchemas) {
const lines = [
'// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.',
'// Existing field validators are preserved when this file is synced again.',
"import { z } from 'zod';",
'',
];
for (const schema of schemas) {
if (schema.kind === 'alias') {
lines.push(`export const ${schema.schemaName} = ${schema.zod};`);
lines.push('');
continue;
}
const existing = existingSchemas.get(schema.schemaName);
const currentNames = new Set(schema.fields.map((field) => field.name));
lines.push(`export const ${schema.schemaName} = z.object({`);
for (const field of schema.fields) {
const expression = existing?.fields.get(field.name) ?? field.zod;
lines.push(` ${quoteKey(field.name)}: ${expression},`);
}
for (const [name, expression] of existing?.fields ?? []) {
if (currentNames.has(name)) continue;
lines.push(` // TODO: no longer present in Encore type ${schema.namespaceName}.${schema.typeName}`);
lines.push(` ${quoteKey(name)}: ${expression},`);
}
lines.push('});');
lines.push('');
}
return `${lines.join('\n').trimEnd()}\n`;
}
function zodObjectLiteral(initializer) {
if (!ts.isCallExpression(initializer)) return null;
if (!ts.isPropertyAccessExpression(initializer.expression)) return null;
if (initializer.expression.name.text !== 'object') return null;
if (!ts.isIdentifier(initializer.expression.expression)) return null;
if (initializer.expression.expression.text !== 'z') return null;
const [arg] = initializer.arguments;
return arg && ts.isObjectLiteralExpression(arg) ? arg : null;
}
function zodForType(type, namespaceName, typeAliases) {
if (!type) return 'z.unknown()';
if (type.kind === ts.SyntaxKind.StringKeyword) return 'z.string()';
if (type.kind === ts.SyntaxKind.NumberKeyword) return 'z.number()';
if (type.kind === ts.SyntaxKind.BooleanKeyword) return 'z.boolean()';
if (type.kind === ts.SyntaxKind.AnyKeyword) return 'z.any()';
if (type.kind === ts.SyntaxKind.UnknownKeyword) return 'z.unknown()';
if (ts.isArrayTypeNode(type)) {
return `z.array(${zodForType(type.elementType, namespaceName, typeAliases)})`;
}
if (ts.isUnionTypeNode(type)) {
const literals = type.types.filter(ts.isLiteralTypeNode);
if (literals.length === type.types.length && literals.length > 0) {
return `z.union([${literals.map((literal) => zodLiteral(literal)).join(', ')}])`;
}
return 'z.unknown()';
}
if (ts.isTypeLiteralNode(type)) {
const fields = type.members
.filter(ts.isPropertySignature)
.map((member) => `${quoteKey(propertyName(member.name))}: ${zodForType(member.type, namespaceName, typeAliases)}`);
return `z.object({ ${fields.join(', ')} })`;
}
if (ts.isTypeReferenceNode(type)) {
const ref = typeNameText(type.typeName);
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
const aliasType = typeAliases.get(qualifiedRef);
if (aliasType) return zodForType(aliasType, namespaceName, typeAliases);
return `z.lazy(() => ${schemaNameFromReference(ref, namespaceName)})`;
}
return 'z.unknown()';
}
function zodLiteral(literal) {
const node = literal.literal;
if (ts.isStringLiteral(node)) return `z.literal(${JSON.stringify(node.text)})`;
if (ts.isNumericLiteral(node)) return `z.literal(${node.text})`;
if (node.kind === ts.SyntaxKind.TrueKeyword) return 'z.literal(true)';
if (node.kind === ts.SyntaxKind.FalseKeyword) return 'z.literal(false)';
return 'z.unknown()';
}
function schemaNameFromReference(ref, namespaceName) {
if (ref.includes('.')) {
const [ns, name] = ref.split('.');
return schemaName(ns, name);
}
return schemaName(namespaceName, ref);
}
function schemaName(namespaceName, typeName) {
return `${pascal(namespaceName)}${pascal(typeName)}Schema`;
}
function propertyName(name) {
if (!name) return '';
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return '';
}
function quoteKey(key) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
function typeNameText(name) {
if (ts.isIdentifier(name)) return name.text;
if (ts.isQualifiedName(name)) return `${typeNameText(name.left)}.${name.right.text}`;
return 'unknown';
}
function pascal(value) {
return value
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join('');
}
function isExportedNamespace(node) {
return ts.isModuleDeclaration(node) && isExported(node) && ts.isIdentifier(node.name);
}
function isExported(node) {
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
}

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.quasar/tsconfig.json"
}

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module encore.app
go 1.26
require (
encore.dev v1.57.5
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.42.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

30
go.sum Normal file
View File

@@ -0,0 +1,30 @@
encore.dev v1.57.5 h1:gpcE2XF4Qvh0IzD+seLf9DPV9q8Mzr7bMaNJfpF2xuc=
encore.dev v1.57.5/go.mod h1:lK8vSJG6uhYeUwT87/FEpcLdiN98QUcotd3gxRX0xDw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

62
hello/hello.go Normal file
View File

@@ -0,0 +1,62 @@
// Service hello implements a simple hello world REST API.
package hello
import (
"context"
)
// Welcome to Encore!
// This is a simple "Hello World" project to get you started.
//
// To run it, execute "encore run" in your favorite shell.
// ==================================================================
// This is a public REST API that responds with a personalized greeting.
// Learn more about defining APIs with Encore:
// https://encore.dev/docs/primitives/services-and-apis
//
// To call it, run in your terminal:
//
// curl http://localhost:4000/hello/World
//
//encore:api public path=/hello/:name
func World(ctx context.Context, name string) (*Response, error) {
msg := "Hello, " + name + "!"
return &Response{Message: msg}, nil
}
type Response struct {
Message string
}
// ==================================================================
// Encore comes with a built-in local development dashboard for
// exploring your API, viewing documentation, debugging with
// distributed tracing, and more:
//
// http://localhost:9400
//
// ==================================================================
// Next steps
//
// 1. Deploy your application to the cloud
//
// git add -A .
// git commit -m 'Commit message'
// git push encore
//
// 2. To continue exploring Encore, check out some of these topics:
//
// Defining Services: https://encore.dev/docs/go/primitives/services
// Defining APIs: https://encore.dev/docs/go/primitives/defining-apis
// Using SQL databases: https://encore.dev/docs/go/primitives/databases
// Using Pub/Sub: https://encore.dev/docs/go/primitives/pubsub
// Authenticating users: https://encore.dev/docs/go/develop/auth
// Building a REST API: https://encore.dev/docs/go/tutorials/rest-api
// Building an Event-Driven app: https://encore.dev/docs/go/tutorials/uptime
// Building a Slack bot: https://encore.dev/docs/go/tutorials/slack-bot
// Example apps repo: https://github.com/encoredev/examples

22
hello/hello_test.go Normal file
View File

@@ -0,0 +1,22 @@
package hello
import (
"context"
"strings"
"testing"
)
// Run tests using `encore test`, which compiles the Encore app and then runs `go test`.
// It supports all the same flags that the `go test` command does.
// You automatically get tracing for tests in the local dev dash: http://localhost:9400
// Learn more: https://encore.dev/docs/go/develop/testing
func TestWorld(t *testing.T) {
const in = "Jane Doe"
resp, err := World(context.Background(), in)
if err != nil {
t.Fatal(err)
}
if got := resp.Message; !strings.Contains(got, in) {
t.Errorf("World(%q) = %q, expected to contain %q", in, got, in)
}
}

69
hello/index.go Normal file
View File

@@ -0,0 +1,69 @@
package hello
import (
"net/http"
"strings"
"encore.dev"
)
// Landing page with usage instructions.
//
//encore:api public raw path=/!path
func Index(w http.ResponseWriter, req *http.Request) {
baseUrl := encore.Meta().APIBaseURL.String()
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(strings.ReplaceAll(landingPage, "{{baseUrl}}", baseUrl)))
}
const landingPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 2rem; max-width: 720px; margin: 0 auto; line-height: 1.6; }
h1 { font-size: 1.75rem; margin-bottom: 0.5rem; color: #fff; }
h2 { font-size: 1.1rem; margin-top: 2rem; margin-bottom: 0.75rem; color: #fff; }
p { margin-bottom: 1rem; color: #a3a3a3; }
code { background: #1a1a1a; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; color: #e5e5e5; }
pre { background: #1a1a1a; border: 1px solid #262626; border-radius: 8px; padding: 1rem; overflow-x: auto; margin-bottom: 1rem; }
pre code { background: none; padding: 0; }
.endpoint { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.method { font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 4px; font-family: monospace; }
.get { background: #15803d; color: #fff; }
.path { font-family: monospace; color: #e5e5e5; }
.desc { color: #737373; font-size: 0.9rem; margin-bottom: 1.25rem; }
a { color: #60a5fa; }
.badge { display: inline-block; background: #15803d; color: #fff; font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 999px; margin-left: 0.5rem; font-weight: 600; vertical-align: middle; position: relative; top: -0.15em; }
</style>
</head>
<body>
<h1>Hello World <span class="badge">Encore.go</span></h1>
<p>A simple REST API to get you started with Encore. This is the simplest possible Encore app, with a single endpoint that returns a greeting.</p>
<p>Explore and test endpoints in the <a href="http://localhost:9400/">Local Dashboard</a> when running locally. When deployed to <a href="https://app.encore.cloud">Encore Cloud</a>, use the Service Catalog to call endpoints and view traces to see how requests flow between services.</p>
<h2>Try it</h2>
<div class="endpoint">
<span class="method get">GET</span>
<span class="path">/hello/:name</span>
<code>hello.World</code>
</div>
<p class="desc">Returns a personalized greeting.</p>
<pre><code>curl {{baseUrl}}/hello/World</code></pre>
<h2>Next steps</h2>
<p>Check out these topics to keep building:</p>
<p>
<a href="https://encore.dev/docs/go/tutorials/rest-api">Building a REST API</a> ·
<a href="https://encore.dev/docs/go/primitives/services">Defining Services</a> ·
<a href="https://encore.dev/docs/go/primitives/databases">Using SQL Databases</a> ·
<a href="https://encore.dev/docs/go/primitives/pubsub">Using Pub/Sub</a>
</p>
</body>
</html>`

BIN
mails/.DS_Store vendored Normal file

Binary file not shown.

98
mails/mails.go Normal file
View File

@@ -0,0 +1,98 @@
// Service mails handles transactional email delivery.
package mails
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"encore.dev/beta/errs"
"encore.dev/types/uuid"
)
const outboxDir = "mails/outbox"
type TransactionalMailParams struct {
To string `json:"to"`
Subject string `json:"subject"`
TextBody string `json:"text_body"`
HTMLBody string `json:"html_body"`
Metadata map[string]string `json:"metadata"`
}
type TransactionalMail struct {
ID uuid.UUID `json:"id"`
To string `json:"to"`
Subject string `json:"subject"`
TextBody string `json:"text_body"`
HTMLBody string `json:"html_body"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
// SendTransactional records a transactional email in the local outbox.
// TODO: replace the file-backed outbox with an SMTP sender.
//
//encore:api private method=POST path=/mails/transactional
func SendTransactional(ctx context.Context, p *TransactionalMailParams) (*TransactionalMail, error) {
if strings.TrimSpace(p.To) == "" {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "recipient email is required"}
}
if strings.TrimSpace(p.Subject) == "" {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "email subject is required"}
}
id, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate mail id")
}
mail := TransactionalMail{
ID: id,
To: p.To,
Subject: p.Subject,
TextBody: p.TextBody,
HTMLBody: p.HTMLBody,
Metadata: p.Metadata,
CreatedAt: time.Now().UTC(),
}
if mail.Metadata == nil {
mail.Metadata = map[string]string{}
}
if err := saveToOutbox(ctx, &mail); err != nil {
return nil, err
}
return &mail, nil
}
func saveToOutbox(ctx context.Context, mail *TransactionalMail) error {
if err := ctx.Err(); err != nil {
return err
}
if err := os.MkdirAll(outboxDir, 0o755); err != nil {
return errs.WrapCode(err, errs.Internal, "failed to create mail outbox")
}
payload, err := json.MarshalIndent(mail, "", " ")
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to encode mail")
}
baseFilename := mail.CreatedAt.Format("20060102T150405Z") + "_" + mail.ID.String()
jsonPath := filepath.Join(outboxDir, baseFilename+".json")
if err := os.WriteFile(jsonPath, payload, 0o644); err != nil {
return errs.WrapCode(err, errs.Internal, "failed to write mail to outbox")
}
if strings.TrimSpace(mail.HTMLBody) != "" {
htmlPath := filepath.Join(outboxDir, baseFilename+".html")
if err := os.WriteFile(htmlPath, []byte(mail.HTMLBody), 0o644); err != nil {
return errs.WrapCode(err, errs.Internal, "failed to write mail html to outbox")
}
}
return nil
}

3
mails/outbox/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.json
*.html
!.gitkeep

1
mails/outbox/.gitkeep Normal file
View File

@@ -0,0 +1 @@

431
mysql.sql Normal file

File diff suppressed because one or more lines are too long

54
profiles/avatars.go Normal file
View File

@@ -0,0 +1,54 @@
package profiles
import (
"encoding/json"
"io"
"net/http"
"encore.dev/beta/errs"
"encore.dev/storage/objects"
"encore.dev/types/uuid"
)
// avatars stores user avatar images. It is public so the returned URLs can be
// rendered directly by browsers without authentication.
var avatars = objects.NewBucket("avatars", objects.BucketConfig{
Public: true,
})
type uploadAvatarResponse struct {
URL string `json:"url"`
}
// UploadAvatar stores the request body as a new avatar image and returns its
// public URL. The caller is expected to persist the URL via the profile
// endpoints. Each upload gets a fresh key, so re-uploading never overwrites.
//
//encore:api auth raw method=POST path=/profiles/avatar
func UploadAvatar(w http.ResponseWriter, req *http.Request) {
key, err := uuid.NewV4()
if err != nil {
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to generate avatar key"))
return
}
contentType := req.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
writer := avatars.Upload(req.Context(), key.String(),
objects.WithUploadAttrs(objects.UploadAttrs{ContentType: contentType}))
if _, err := io.Copy(writer, req.Body); err != nil {
writer.Abort(err)
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to upload avatar"))
return
}
if err := writer.Close(); err != nil {
errs.HTTPError(w, errs.WrapCode(err, errs.Internal, "failed to store avatar"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(uploadAvatarResponse{URL: avatars.PublicURL(key.String()).String()})
}

132
profiles/login.go Normal file
View File

@@ -0,0 +1,132 @@
package profiles
import (
"context"
"errors"
"sync"
"time"
"encore.app/auth"
"encore.dev/beta/errs"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
)
const loginTimeout = 10 * time.Second
type LoginParams struct {
UserEmail string `json:"user_email"`
Password string `json:"password" encore:"sensitive"`
}
type LoginResponse struct {
Token uuid.UUID `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
var loginRequests = pubsub.TopicRef[pubsub.Publisher[*auth.LoginRequested]](auth.LoginRequests)
// Login is the public entry point for authenticating a user. It first
// resolves the given email to a user ID via the local profiles database.
// The actual credential check lives in the auth service; rather than calling
// it directly, this hands the request off over Pub/Sub (publishing to
// auth.LoginRequests) and waits for the matching auth.LoginCompleted reply
// on auth.LoginResults before responding to the caller.
//
// NOTE: the wait is implemented with an in-process registry keyed by request
// ID, so the instance that publishes the request must be the one that
// receives the reply. That holds for local development (a single instance)
// but not for a horizontally scaled deployment, which would need a shared
// mechanism instead (e.g. a results table polled with retry_until).
//
//encore:api public method=POST path=/auth/login
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
var userID uuid.UUID
err := db.QueryRow(ctx, `
SELECT user_id FROM user_profiles WHERE email = $1
`, p.UserEmail).Scan(&userID)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to look up user")
}
requestID, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate request id")
}
wait := loginWaiters.register(requestID)
defer loginWaiters.cancel(requestID)
if _, err := loginRequests.Publish(ctx, &auth.LoginRequested{
RequestID: requestID,
UserID: userID,
Password: p.Password,
}); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to publish login request")
}
select {
case result := <-wait:
if !result.OK {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
}
return &LoginResponse{Token: result.Token, ExpiresAt: result.ExpiresAt}, nil
case <-time.After(loginTimeout):
return nil, &errs.Error{Code: errs.DeadlineExceeded, Message: "login timed out"}
case <-ctx.Done():
return nil, ctx.Err()
}
}
var _ = pubsub.NewSubscription(
auth.LoginResults, "deliver-to-waiting-login",
pubsub.SubscriptionConfig[*auth.LoginCompleted]{
Handler: func(ctx context.Context, ev *auth.LoginCompleted) error {
loginWaiters.deliver(ev)
return nil
},
},
)
// loginWaiters lets the synchronous Login handler above wait for the
// asynchronous LoginCompleted reply that matches its RequestID.
var loginWaiters = newWaiterRegistry()
type waiterRegistry struct {
mu sync.Mutex
waiting map[uuid.UUID]chan *auth.LoginCompleted
}
func newWaiterRegistry() *waiterRegistry {
return &waiterRegistry{waiting: make(map[uuid.UUID]chan *auth.LoginCompleted)}
}
func (r *waiterRegistry) register(id uuid.UUID) <-chan *auth.LoginCompleted {
ch := make(chan *auth.LoginCompleted, 1)
r.mu.Lock()
r.waiting[id] = ch
r.mu.Unlock()
return ch
}
func (r *waiterRegistry) cancel(id uuid.UUID) {
r.mu.Lock()
delete(r.waiting, id)
r.mu.Unlock()
}
func (r *waiterRegistry) deliver(ev *auth.LoginCompleted) {
r.mu.Lock()
ch, ok := r.waiting[ev.RequestID]
if ok {
delete(r.waiting, ev.RequestID)
}
r.mu.Unlock()
if ok {
ch <- ev
}
}

13
profiles/me.go Normal file
View File

@@ -0,0 +1,13 @@
package profiles
import (
"context"
)
// Me returns the profile of the currently authenticated caller, or nil if
// the request is not authenticated.
//
//encore:api public method=GET path=/auth/me
func Me(ctx context.Context) (*Profile, error) {
return Get(ctx)
}

View File

@@ -0,0 +1 @@
ALTER TABLE personal_data ADD COLUMN cap TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,8 @@
CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
bio TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,10 @@
DROP TABLE user_profiles;
CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY,
display_name TEXT NOT NULL,
bio TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,10 @@
ALTER TABLE user_profiles ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
CREATE TABLE sessions (
token UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES user_profiles(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);

View File

@@ -0,0 +1,3 @@
DROP TABLE sessions;
ALTER TABLE user_profiles DROP COLUMN password_hash;

View File

@@ -0,0 +1,7 @@
ALTER TABLE user_profiles ADD COLUMN email TEXT NOT NULL DEFAULT '';
-- Existing rows predate the email column; give each a unique placeholder
-- derived from its user ID so the uniqueness constraint below can apply.
UPDATE user_profiles SET email = user_id || '@example.invalid' WHERE email = '';
CREATE UNIQUE INDEX user_profiles_email_idx ON user_profiles (email);

View File

@@ -0,0 +1 @@
ALTER TABLE user_profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';

View File

@@ -0,0 +1 @@
ALTER TABLE user_profiles ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_profiles DROP COLUMN bio;
ALTER TABLE user_profiles ADD COLUMN is_artist BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,10 @@
CREATE TABLE personal_data (
user_id UUID PRIMARY KEY REFERENCES user_profiles (user_id) ON DELETE CASCADE,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

116
profiles/personal_data.go Normal file
View File

@@ -0,0 +1,116 @@
package profiles
import (
"context"
"errors"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
// PersonalData holds the personal details linked one-to-one to a user profile.
type PersonalData struct {
UserID uuid.UUID `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PersonalDataParams are the editable fields of a user's personal data.
type PersonalDataParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
}
// GetPersonalData returns the authenticated user's own personal data.
//
//encore:api auth method=GET path=/profiles/personal-data
func GetPersonalData(ctx context.Context) (*PersonalData, error) {
userID, err := authedUserID()
if err != nil {
return nil, err
}
pd := PersonalData{UserID: userID}
err = db.QueryRow(ctx, `
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
FROM personal_data WHERE user_id = $1
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal data")
}
return &pd, nil
}
// UpsertPersonalData creates or replaces the authenticated user's own personal data.
//
//encore:api auth method=PUT path=/profiles/personal-data
func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalData, error) {
userID, err := authedUserID()
if err != nil {
return nil, err
}
pd := PersonalData{
UserID: userID,
FirstName: p.FirstName,
LastName: p.LastName,
Address: p.Address,
Cap: p.Cap,
City: p.City,
Country: p.Country,
}
err = db.QueryRow(ctx, `
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE
SET first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
address = EXCLUDED.address,
cap = EXCLUDED.cap,
city = EXCLUDED.city,
country = EXCLUDED.country,
updated_at = NOW()
RETURNING created_at, updated_at
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
if isForeignKeyViolation(err) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal data")
}
return &pd, nil
}
// authedUserID returns the UUID of the authenticated caller.
func authedUserID() (uuid.UUID, error) {
uid, ok := auth.UserID()
if !ok {
return uuid.Nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"}
}
userID, err := uuid.FromString(string(uid))
if err != nil {
return uuid.Nil, errs.WrapCode(err, errs.Internal, "invalid user id")
}
return userID, nil
}
// isForeignKeyViolation reports whether err is a Postgres foreign key constraint violation.
func isForeignKeyViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23503"
}

168
profiles/profiles.go Normal file
View File

@@ -0,0 +1,168 @@
// Service profiles stores and serves user profile information.
package profiles
import (
"context"
"errors"
"time"
authsvc "encore.app/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
type Profile struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
IsArtist bool `json:"is_artist"`
Role string `json:"role"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProfileParams struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
type RegisterParams struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Password string `json:"password" encore:"sensitive"`
}
// Insert registers a new profile with a server-generated UUID v4, delegating
// credential setup to the auth service.
//
//encore:api public method=POST path=/profiles
func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
userID, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate profile id")
}
profile := Profile{UserID: userID, Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
err = db.QueryRow(ctx, `
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
VALUES ($1, $2, $3, $4)
RETURNING role, status, is_artist, created_at, updated_at
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if isUniqueViolation(err) {
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create profile")
}
if err := authsvc.Register(ctx, &authsvc.RegisterParams{UserID: userID, Password: p.Password}); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to register credentials")
}
return &profile, nil
}
// Update replaces the data of the authenticated user's own profile.
//
//encore:api auth method=PUT path=/profiles
func Update(ctx context.Context, p *ProfileParams) (*Profile, error) {
uid, ok := auth.UserID()
if !ok {
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
}
userID, err := uuid.FromString(string(uid))
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
}
if err := requireSelf(userID); err != nil {
return nil, err
}
profile := Profile{UserID: userID, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL}
err = db.QueryRow(ctx, `
UPDATE user_profiles
SET display_name = $2, avatar_url = $3, updated_at = NOW()
WHERE user_id = $1
RETURNING email, role, status, is_artist, created_at, updated_at
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to update profile")
}
return &profile, nil
}
// Get returns the profile for the given user. For the caller's own profile
// (including when not authenticated), see MyProfile.
//
//encore:api public method=GET path=/profiles/profile
func Get(ctx context.Context) (*Profile, error) {
uid, ok := auth.UserID()
if !ok {
return nil, nil
}
userID, err := uuid.FromString(string(uid))
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "invalid user id")
}
p := Profile{UserID: userID}
err = db.QueryRow(ctx, `
SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1
`, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile")
}
return &p, nil
}
// Delete removes the authenticated user's own profile.
//
//encore:api auth method=DELETE path=/profiles
func Delete(ctx context.Context) error {
uid, ok := auth.UserID()
if !ok {
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
}
userID, err := uuid.FromString(string(uid))
if err != nil {
return errs.WrapCode(err, errs.Internal, "invalid user id")
}
if err := requireSelf(userID); err != nil {
return err
}
res, err := db.Exec(ctx, `
DELETE FROM user_profiles WHERE user_id = $1
`, userID)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to delete profile")
}
if res.RowsAffected() == 0 {
return &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
return nil
}
// requireSelf returns an error unless the authenticated caller is the user identified by userID.
func requireSelf(userID uuid.UUID) error {
if uid, ok := auth.UserID(); !ok || uid != auth.UID(userID.String()) {
return &errs.Error{Code: errs.PermissionDenied, Message: "cannot act on another user's profile"}
}
return nil
}
// isUniqueViolation reports whether err is a Postgres unique constraint violation.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

38
profiles/status.go Normal file
View File

@@ -0,0 +1,38 @@
package profiles
type Status int
const (
StatusActive Status = iota
StatusInactive
StatusSuspended
StatusDeleted
StatusPending
StatusWaitingDeletion
StatusBanned
)
// StatusOption describes a valid profile status for display in admin UIs.
type StatusOption struct {
Name string `json:"name"`
Value Status `json:"value"`
Updatable bool `json:"updatable"`
}
// Statuses returns every valid profile status with a display name and its underlying value.
func Statuses() []StatusOption {
return []StatusOption{
{Name: "Active", Value: StatusActive, Updatable: true},
{Name: "Inactive", Value: StatusInactive, Updatable: true},
{Name: "Suspended", Value: StatusSuspended, Updatable: true},
{Name: "Deleted", Value: StatusDeleted, Updatable: false},
{Name: "Pending", Value: StatusPending, Updatable: true},
{Name: "Waiting deletion", Value: StatusWaitingDeletion, Updatable: false},
{Name: "Banned", Value: StatusBanned, Updatable: true},
}
}
// IsValid reports whether s is one of the defined Status values.
func (s Status) IsValid() bool {
return s >= StatusActive && s <= StatusBanned
}

Some files were not shown because too many files have changed in this diff Show More