mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5596740cd2 | ||
|
|
bb86a51b05 | ||
|
|
72bdf524c2 | ||
|
|
461be2afca | ||
|
|
1cc38d6a5c | ||
|
|
50bd2ab86e | ||
|
|
5adb8fbad7 | ||
|
|
dea2dcfde8 | ||
|
|
ba8367f637 | ||
|
|
b87cdc8164 | ||
|
|
0f51e51f63 |
5
.devcontainer/Dockerfile
Normal file
5
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16-bullseye
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
||||
|
||||
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
|
||||
40
.devcontainer/devcontainer.json
Normal file
40
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,40 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "18-bullseye"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
7745,
|
||||
3000
|
||||
],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "go install github.com/go-task/task/v3/cmd/task@latest && npm install -g pnpm && task setup",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"golang": "1.19"
|
||||
}
|
||||
}
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [hay-kot]
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: "Bug Report"
|
||||
description: "submit a bug report for the current release"
|
||||
description: "Submit a bug report for the current release"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: "Feature Request"
|
||||
description: "submit a feature request for the current release"
|
||||
description: "Submit a feature request for the current release"
|
||||
labels: ["feature-request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
|
||||
@@ -4,6 +4,12 @@ env:
|
||||
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1
|
||||
|
||||
tasks:
|
||||
setup:
|
||||
desc: Install dependencies
|
||||
cmds:
|
||||
- go install github.com/swaggo/swag/cmd/swag@latest
|
||||
- cd backend && go mod tidy
|
||||
- cd frontend && pnpm install --shamefully-hoist
|
||||
generate:
|
||||
desc: |
|
||||
Generates collateral files from the backend project
|
||||
@@ -27,6 +33,7 @@ tasks:
|
||||
- "./scripts/process-types.py"
|
||||
generates:
|
||||
- "./frontend/lib/api/types/data-contracts.ts"
|
||||
- "./backend/ent/schema"
|
||||
- "./backend/app/api/docs/swagger.json"
|
||||
- "./backend/app/api/docs/swagger.yaml"
|
||||
|
||||
|
||||
@@ -21,6 +21,63 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/v1/groups": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.Group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Updates some fields of the current users group",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User Data",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.GroupUpdate"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.Group"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/invitations": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -32,7 +89,7 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"User"
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user",
|
||||
"parameters": [
|
||||
@@ -1116,6 +1173,37 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemAttachment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -13,6 +13,63 @@
|
||||
},
|
||||
"basePath": "/api",
|
||||
"paths": {
|
||||
"/v1/groups": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.Group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Updates some fields of the current users group",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User Data",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.GroupUpdate"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.Group"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/invitations": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -24,7 +81,7 @@
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"User"
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user",
|
||||
"parameters": [
|
||||
@@ -1108,6 +1165,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemAttachment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -9,6 +9,26 @@ definitions:
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
repo.Group:
|
||||
properties:
|
||||
createdAt:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
repo.GroupUpdate:
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
repo.ItemAttachment:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -415,6 +435,40 @@ info:
|
||||
title: Go API Templates
|
||||
version: "1.0"
|
||||
paths:
|
||||
/v1/groups:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/repo.Group'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get the current user's group
|
||||
tags:
|
||||
- Group
|
||||
put:
|
||||
parameters:
|
||||
- description: User Data
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/repo.GroupUpdate'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/repo.Group'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Updates some fields of the current users group
|
||||
tags:
|
||||
- Group
|
||||
/v1/groups/invitations:
|
||||
post:
|
||||
parameters:
|
||||
@@ -435,7 +489,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: Get the current user
|
||||
tags:
|
||||
- User
|
||||
- Group
|
||||
/v1/items:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -110,7 +110,7 @@ func run(cfg *config.Config) error {
|
||||
|
||||
app.db = c
|
||||
app.repos = repo.New(c, cfg.Storage.Data)
|
||||
app.services = services.NewServices(app.repos)
|
||||
app.services = services.New(app.repos)
|
||||
|
||||
// =========================================================================
|
||||
// Start Server
|
||||
@@ -138,6 +138,14 @@ func run(cfg *config.Config) error {
|
||||
Msg("failed to purge expired tokens")
|
||||
}
|
||||
})
|
||||
go app.startBgTask(time.Duration(24)*time.Hour, func() {
|
||||
_, err := app.repos.Groups.InvitationPurge(context.Background())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to purge expired invitations")
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Remove through external API that does setup
|
||||
if cfg.Demo {
|
||||
|
||||
@@ -44,6 +44,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||
v1Base := v1.BaseUrlFunc(prefix)
|
||||
v1Ctrl := v1.NewControllerV1(a.services,
|
||||
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
|
||||
v1.WithRegistration(a.conf.AllowRegistration),
|
||||
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
|
||||
)
|
||||
r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
|
||||
@@ -71,6 +72,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||
|
||||
r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate())
|
||||
|
||||
// TODO: I don't like /groups being the URL for users
|
||||
r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet())
|
||||
r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate())
|
||||
|
||||
r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll())
|
||||
r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
|
||||
r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())
|
||||
|
||||
@@ -19,10 +19,17 @@ func WithDemoStatus(demoStatus bool) func(*V1Controller) {
|
||||
}
|
||||
}
|
||||
|
||||
func WithRegistration(allowRegistration bool) func(*V1Controller) {
|
||||
return func(ctrl *V1Controller) {
|
||||
ctrl.allowRegistration = allowRegistration
|
||||
}
|
||||
}
|
||||
|
||||
type V1Controller struct {
|
||||
svc *services.AllServices
|
||||
maxUploadSize int64
|
||||
isDemo bool
|
||||
allowRegistration bool
|
||||
}
|
||||
|
||||
type (
|
||||
@@ -54,6 +61,7 @@ func BaseUrlFunc(prefix string) func(s string) string {
|
||||
func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller {
|
||||
ctrl := &V1Controller{
|
||||
svc: svc,
|
||||
allowRegistration: true,
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
|
||||
@@ -2,8 +2,10 @@ package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hay-kot/homebox/backend/internal/repo"
|
||||
"github.com/hay-kot/homebox/backend/internal/services"
|
||||
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -22,9 +24,63 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// HandleUserSelf godoc
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group
|
||||
// @Tags Group
|
||||
// @Produce json
|
||||
// @Success 200 {object} repo.Group
|
||||
// @Router /v1/groups [Get]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
group, err := ctrl.svc.Group.Get(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to get group")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ
|
||||
|
||||
server.Respond(w, http.StatusOK, group)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupUpdate godoc
|
||||
// @Summary Updates some fields of the current users group
|
||||
// @Tags Group
|
||||
// @Produce json
|
||||
// @Param payload body repo.GroupUpdate true "User Data"
|
||||
// @Success 200 {object} repo.Group
|
||||
// @Router /v1/groups [Put]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := repo.GroupUpdate{}
|
||||
|
||||
if err := server.Decode(r, &data); err != nil {
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to update group")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
|
||||
server.Respond(w, http.StatusOK, group)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupInvitationsCreate godoc
|
||||
// @Summary Get the current user
|
||||
// @Tags User
|
||||
// @Tags Group
|
||||
// @Produce json
|
||||
// @Param payload body GroupInvitationCreate true "User Data"
|
||||
// @Success 200 {object} GroupInvitation
|
||||
@@ -36,7 +92,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
|
||||
|
||||
if err := server.Decode(r, &data); err != nil {
|
||||
log.Err(err).Msg("failed to decode user registration data")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +102,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
|
||||
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
token, err := ctrl.svc.User.NewInvitation(ctx, data.Uses, data.ExpiresAt)
|
||||
token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create new token")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
|
||||
@@ -27,6 +27,11 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !ctrl.allowRegistration && regData.GroupToken == "" {
|
||||
server.RespondError(w, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := ctrl.svc.User.RegisterUser(r.Context(), regData)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to register user")
|
||||
|
||||
@@ -121,6 +121,9 @@ const DefaultCurrency = CurrencyUsd
|
||||
// Currency values.
|
||||
const (
|
||||
CurrencyUsd Currency = "usd"
|
||||
CurrencyEur Currency = "eur"
|
||||
CurrencyGbp Currency = "gbp"
|
||||
CurrencyJpy Currency = "jpy"
|
||||
)
|
||||
|
||||
func (c Currency) String() string {
|
||||
@@ -130,7 +133,7 @@ func (c Currency) String() string {
|
||||
// CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save.
|
||||
func CurrencyValidator(c Currency) error {
|
||||
switch c {
|
||||
case CurrencyUsd:
|
||||
case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("group: invalid enum value for currency field: %q", c)
|
||||
|
||||
@@ -127,7 +127,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime},
|
||||
{Name: "updated_at", Type: field.TypeTime},
|
||||
{Name: "name", Type: field.TypeString, Size: 255},
|
||||
{Name: "currency", Type: field.TypeEnum, Enums: []string{"usd"}, Default: "usd"},
|
||||
{Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy"}, Default: "usd"},
|
||||
}
|
||||
// GroupsTable holds the schema information for the "groups" table.
|
||||
GroupsTable = &schema.Table{
|
||||
|
||||
@@ -27,7 +27,7 @@ func (Group) Fields() []ent.Field {
|
||||
NotEmpty(),
|
||||
field.Enum("currency").
|
||||
Default("usd").
|
||||
Values("usd"), // TODO: add more currencies
|
||||
Values("usd", "eur", "gbp", "jpy"), // TODO: add more currencies
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
Mailer MailerConf `yaml:"mailer"`
|
||||
Swagger SwaggerConf `yaml:"swagger"`
|
||||
Demo bool `yaml:"demo"`
|
||||
AllowRegistration bool `yaml:"disable_registration" conf:"default:true"`
|
||||
}
|
||||
|
||||
type SwaggerConf struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hay-kot/homebox/backend/ent"
|
||||
"github.com/hay-kot/homebox/backend/ent/group"
|
||||
"github.com/hay-kot/homebox/backend/ent/groupinvitationtoken"
|
||||
)
|
||||
|
||||
@@ -15,11 +16,16 @@ type GroupRepository struct {
|
||||
|
||||
type (
|
||||
Group struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Currency string
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
GroupUpdate struct {
|
||||
Name string `json:"name"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
GroupInvitationCreate struct {
|
||||
@@ -69,6 +75,17 @@ func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group,
|
||||
Save(ctx))
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data GroupUpdate) (Group, error) {
|
||||
currency := group.Currency(data.Currency)
|
||||
|
||||
entity, err := r.db.Group.UpdateOneID(ID).
|
||||
SetName(data.Name).
|
||||
SetCurrency(currency).
|
||||
Save(ctx)
|
||||
|
||||
return mapToGroupErr(entity, err)
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, error) {
|
||||
return mapToGroupErr(r.db.Group.Get(ctx, id))
|
||||
}
|
||||
|
||||
@@ -18,3 +18,16 @@ func Test_Group_Create(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, g.ID, foundGroup.ID)
|
||||
}
|
||||
|
||||
func Test_Group_Update(t *testing.T) {
|
||||
g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
|
||||
assert.NoError(t, err)
|
||||
|
||||
g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{
|
||||
Name: "test2",
|
||||
Currency: "eur",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test2", g.Name)
|
||||
assert.Equal(t, "eur", g.Currency)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type (
|
||||
Search string `json:"search"`
|
||||
LocationIDs []uuid.UUID `json:"locationIds"`
|
||||
LabelIDs []uuid.UUID `json:"labelIds"`
|
||||
SortBy string `json:"sortBy"`
|
||||
}
|
||||
|
||||
ItemCreate struct {
|
||||
@@ -252,7 +253,8 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
|
||||
}
|
||||
|
||||
items, err := mapItemsSummaryErr(
|
||||
qb.WithLabel().
|
||||
qb.Order(ent.Asc(item.FieldName)).
|
||||
WithLabel().
|
||||
WithLocation().
|
||||
All(ctx),
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ func (r *LabelRepository) GetOneByGroup(ctx context.Context, gid, ld uuid.UUID)
|
||||
func (r *LabelRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]LabelSummary, error) {
|
||||
return mapLabelsOut(r.db.Label.Query().
|
||||
Where(label.HasGroupWith(group.ID(groupId))).
|
||||
Order(ent.Asc(label.FieldName)).
|
||||
WithGroup().
|
||||
All(ctx),
|
||||
)
|
||||
|
||||
@@ -94,7 +94,9 @@ func (r *LocationRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]L
|
||||
locations
|
||||
WHERE
|
||||
locations.group_locations = ?
|
||||
`
|
||||
ORDER BY
|
||||
locations.name ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.Sql().QueryContext(ctx, query, groupId)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,18 +4,20 @@ import "github.com/hay-kot/homebox/backend/internal/repo"
|
||||
|
||||
type AllServices struct {
|
||||
User *UserService
|
||||
Group *GroupService
|
||||
Location *LocationService
|
||||
Labels *LabelService
|
||||
Items *ItemService
|
||||
}
|
||||
|
||||
func NewServices(repos *repo.AllRepos) *AllServices {
|
||||
func New(repos *repo.AllRepos) *AllServices {
|
||||
if repos == nil {
|
||||
panic("repos cannot be nil")
|
||||
}
|
||||
|
||||
return &AllServices{
|
||||
User: &UserService{repos},
|
||||
Group: &GroupService{repos},
|
||||
Location: &LocationService{repos},
|
||||
Labels: &LabelService{repos},
|
||||
Items: &ItemService{
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
tClient = client
|
||||
tRepos = repo.New(tClient, os.TempDir()+"/homebox")
|
||||
tSvc = NewServices(tRepos)
|
||||
tSvc = New(tRepos)
|
||||
defer client.Close()
|
||||
|
||||
bootstrap()
|
||||
|
||||
47
backend/internal/services/service_group.go
Normal file
47
backend/internal/services/service_group.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hay-kot/homebox/backend/internal/repo"
|
||||
"github.com/hay-kot/homebox/backend/pkgs/hasher"
|
||||
)
|
||||
|
||||
type GroupService struct {
|
||||
repos *repo.AllRepos
|
||||
}
|
||||
|
||||
func (svc *GroupService) Get(ctx Context) (repo.Group, error) {
|
||||
return svc.repos.Groups.GroupByID(ctx.Context, ctx.GID)
|
||||
}
|
||||
|
||||
func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) {
|
||||
if data.Name == "" {
|
||||
data.Name = ctx.User.GroupName
|
||||
}
|
||||
|
||||
if data.Currency == "" {
|
||||
return repo.Group{}, errors.New("currency cannot be empty")
|
||||
}
|
||||
|
||||
data.Currency = strings.ToLower(data.Currency)
|
||||
|
||||
return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data)
|
||||
}
|
||||
|
||||
func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
|
||||
token := hasher.GenerateToken()
|
||||
|
||||
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
|
||||
Token: token.Hash,
|
||||
Uses: uses,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token.Raw, nil
|
||||
}
|
||||
@@ -186,21 +186,6 @@ func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error {
|
||||
return svc.repos.Users.Delete(ctx, ID)
|
||||
}
|
||||
|
||||
func (svc *UserService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
|
||||
token := hasher.GenerateToken()
|
||||
|
||||
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
|
||||
Token: token.Hash,
|
||||
Uses: uses,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token.Raw, nil
|
||||
}
|
||||
|
||||
func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) {
|
||||
usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,8 @@ docker run --name=homebox \
|
||||
|
||||
```yml
|
||||
version: "3.4"
|
||||
services:
|
||||
|
||||
services:
|
||||
homebox:
|
||||
image: ghcr.io/hay-kot/homebox:latest
|
||||
container_name: homebox
|
||||
@@ -41,9 +42,10 @@ volumes:
|
||||
| HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production |
|
||||
| HBOX_WEB_PORT | 7745 | port to run the web server on, in you're using docker do not change this |
|
||||
| HBOX_WEB_HOST | | host to run the web server on, in you're using docker do not change this |
|
||||
| HBOX_ALLOW_REGISTRATION | true | allow users to register themselves |
|
||||
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this |
|
||||
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical |
|
||||
| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json |
|
||||
| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used |
|
||||
@@ -76,6 +78,8 @@ volumes:
|
||||
--mailer-from/$HBOX_MAILER_FROM <string>
|
||||
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
|
||||
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
|
||||
--demo/$HBOX_DEMO <bool>
|
||||
--allow-registration/$HBOX_ALLOW_REGISTRATION <bool> (default: true)
|
||||
--help/-h
|
||||
display this help message
|
||||
```
|
||||
3
fly.toml
3
fly.toml
@@ -5,6 +5,9 @@ kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[build.args]
|
||||
COMMIT = "HEAD"
|
||||
VERSION = "nightly"
|
||||
|
||||
[env]
|
||||
PORT = "7745"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const emit = defineEmits(["update:modelValue", "update:value"]);
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
modelValue: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: [Object, String, Boolean] as any,
|
||||
type: [Object, String] as any,
|
||||
default: null,
|
||||
},
|
||||
items: {
|
||||
@@ -37,26 +37,41 @@
|
||||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
value: {
|
||||
valueKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const selectedIdx = ref(-1);
|
||||
|
||||
const internalSelected = useVModel(props, "modelValue", emit);
|
||||
|
||||
watch(selectedIdx, newVal => {
|
||||
internalSelected.value = props.items[newVal];
|
||||
});
|
||||
|
||||
watch(internalSelected, newVal => {
|
||||
if (props.valueKey) {
|
||||
emit("update:value", newVal[props.valueKey]);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function compare(a: any, b: any): boolean {
|
||||
if (props.value != null) {
|
||||
return a[props.value] === b[props.value];
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
return a === b;
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
22
frontend/components/global/Currency.vue
Normal file
22
frontend/components/global/Currency.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
{{ value }}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
amount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = await useFormatCurrency();
|
||||
|
||||
const value = computed(() => {
|
||||
if (!props.amount || props.amount === "0") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fmt(props.amount);
|
||||
});
|
||||
</script>
|
||||
@@ -7,9 +7,8 @@
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-base-content sm:col-span-2 sm:mt-0">
|
||||
<slot :name="detail.slot || detail.name" v-bind="{ detail }">
|
||||
<template v-if="detail.type == 'date'">
|
||||
<DateTime :date="detail.text" />
|
||||
</template>
|
||||
<DateTime v-if="detail.type == 'date'" :date="detail.text" />
|
||||
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
|
||||
<template v-else>
|
||||
{{ detail.text }}
|
||||
</template>
|
||||
@@ -21,11 +20,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DateDetail, Detail } from "./types";
|
||||
import type { CustomDetail, Detail } from "./types";
|
||||
|
||||
defineProps({
|
||||
details: {
|
||||
type: Object as () => (Detail | DateDetail)[],
|
||||
type: Object as () => (Detail | CustomDetail)[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
export type StringLike = string | number | boolean;
|
||||
|
||||
export type DateDetail = {
|
||||
type BaseDetail = {
|
||||
name: string;
|
||||
text: string | Date;
|
||||
slot?: string;
|
||||
type: "date";
|
||||
};
|
||||
|
||||
export type Detail = {
|
||||
name: string;
|
||||
type DateDetail = BaseDetail & {
|
||||
type: "date";
|
||||
text: Date | string;
|
||||
};
|
||||
|
||||
type CurrencyDetail = BaseDetail & {
|
||||
type: "currency";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type CustomDetail = DateDetail | CurrencyDetail;
|
||||
|
||||
export type Detail = BaseDetail & {
|
||||
text: StringLike;
|
||||
slot?: string;
|
||||
type?: "text";
|
||||
};
|
||||
|
||||
export type Details = Array<Detail | CustomDetail>;
|
||||
|
||||
21
frontend/composables/use-formatters.ts
Normal file
21
frontend/composables/use-formatters.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const cache = {
|
||||
currency: "",
|
||||
};
|
||||
|
||||
export function ResetCurrency() {
|
||||
cache.currency = "";
|
||||
}
|
||||
|
||||
export async function useFormatCurrency() {
|
||||
if (!cache.currency) {
|
||||
const client = useUserApi();
|
||||
|
||||
const { data: group } = await client.group.get();
|
||||
|
||||
if (group) {
|
||||
cache.currency = group.currency;
|
||||
}
|
||||
}
|
||||
|
||||
return (value: number | string) => fmtCurrency(value, cache.currency);
|
||||
}
|
||||
@@ -1,35 +1,5 @@
|
||||
import { Ref } from "vue";
|
||||
|
||||
export type DaisyTheme =
|
||||
| "light"
|
||||
| "dark"
|
||||
| "cupcake"
|
||||
| "bumblebee"
|
||||
| "emerald"
|
||||
| "corporate"
|
||||
| "synthwave"
|
||||
| "retro"
|
||||
| "cyberpunk"
|
||||
| "valentine"
|
||||
| "halloween"
|
||||
| "garden"
|
||||
| "forest"
|
||||
| "aqua"
|
||||
| "lofi"
|
||||
| "pastel"
|
||||
| "fantasy"
|
||||
| "wireframe"
|
||||
| "black"
|
||||
| "luxury"
|
||||
| "dracula"
|
||||
| "cmyk"
|
||||
| "autumn"
|
||||
| "business"
|
||||
| "acid"
|
||||
| "lemonade"
|
||||
| "night"
|
||||
| "coffee"
|
||||
| "winter";
|
||||
import { DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export type LocationViewPreferences = {
|
||||
showDetails: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComputedRef } from "vue";
|
||||
import { DaisyTheme } from "./use-preferences";
|
||||
import { DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export interface UseTheme {
|
||||
theme: ComputedRef<DaisyTheme>;
|
||||
|
||||
@@ -1,10 +1,70 @@
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div>
|
||||
<AppToast />
|
||||
<AppHeader />
|
||||
<main class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen">
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useItemStore } from "~~/stores/items";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
/**
|
||||
* Store Provider Initialization
|
||||
*/
|
||||
|
||||
const labelStore = useLabelStore();
|
||||
const reLabel = /\/api\/v1\/labels\/.*/gm;
|
||||
const rmLabelStoreObserver = defineObserver("labelStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reLabel)) {
|
||||
labelStore.refresh();
|
||||
}
|
||||
console.debug("labelStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const locationStore = useLocationStore();
|
||||
const reLocation = /\/api\/v1\/locations\/.*/gm;
|
||||
const rmLocationStoreObserver = defineObserver("locationStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reLocation)) {
|
||||
locationStore.refresh();
|
||||
}
|
||||
console.debug("locationStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const itemStore = useItemStore();
|
||||
const reItem = /\/api\/v1\/items\/.*/gm;
|
||||
const rmItemStoreObserver = defineObserver("itemStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reItem)) {
|
||||
itemStore.refresh();
|
||||
}
|
||||
console.debug("itemStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const eventBus = useEventBus();
|
||||
eventBus.on(
|
||||
EventTypes.ClearStores,
|
||||
() => {
|
||||
labelStore.refresh();
|
||||
itemStore.refresh();
|
||||
locationStore.refresh();
|
||||
},
|
||||
"stores"
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
rmLabelStoreObserver();
|
||||
rmLocationStoreObserver();
|
||||
rmItemStoreObserver();
|
||||
eventBus.off(EventTypes.ClearStores, "stores");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppToast />
|
||||
<AppHeader />
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useItemStore } from "~~/stores/items";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
/**
|
||||
* Store Provider Initialization
|
||||
*/
|
||||
|
||||
const labelStore = useLabelStore();
|
||||
const reLabel = /\/api\/v1\/labels\/.*/gm;
|
||||
const rmLabelStoreObserver = defineObserver("labelStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reLabel)) {
|
||||
labelStore.refresh();
|
||||
}
|
||||
console.debug("labelStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const locationStore = useLocationStore();
|
||||
const reLocation = /\/api\/v1\/locations\/.*/gm;
|
||||
const rmLocationStoreObserver = defineObserver("locationStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reLocation)) {
|
||||
locationStore.refresh();
|
||||
}
|
||||
console.debug("locationStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const itemStore = useItemStore();
|
||||
const reItem = /\/api\/v1\/items\/.*/gm;
|
||||
const rmItemStoreObserver = defineObserver("itemStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reItem)) {
|
||||
itemStore.refresh();
|
||||
}
|
||||
console.debug("itemStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const eventBus = useEventBus();
|
||||
eventBus.on(
|
||||
EventTypes.ClearStores,
|
||||
() => {
|
||||
labelStore.refresh();
|
||||
itemStore.refresh();
|
||||
locationStore.refresh();
|
||||
},
|
||||
"stores"
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
rmLabelStoreObserver();
|
||||
rmLocationStoreObserver();
|
||||
rmItemStoreObserver();
|
||||
eventBus.off(EventTypes.ClearStores, "stores");
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { factories } from "./factories";
|
||||
import { sharedUserClient } from "./test-utils";
|
||||
|
||||
describe("[GET] /api/v1/status", () => {
|
||||
test("server should respond", async () => {
|
||||
@@ -32,43 +31,4 @@ describe("first time user workflow (register, login, join group)", () => {
|
||||
expect(response.status).toBe(204);
|
||||
}
|
||||
});
|
||||
|
||||
test("user should be able to join create join token and have user signup", async () => {
|
||||
// Setup User 1 Token
|
||||
|
||||
const client = await sharedUserClient();
|
||||
const { data: user1 } = await client.user.self();
|
||||
|
||||
const { response, data } = await client.group.createInvitation({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
uses: 1,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.token).toBeTruthy();
|
||||
|
||||
// Create User 2 with token
|
||||
|
||||
const duplicateUser = factories.user();
|
||||
duplicateUser.token = data.token;
|
||||
|
||||
const { response: registerResp } = await api.register(duplicateUser);
|
||||
expect(registerResp.status).toBe(204);
|
||||
|
||||
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
|
||||
expect(loginResp.status).toBe(200);
|
||||
|
||||
// Get Self and Assert
|
||||
|
||||
const client2 = factories.client.user(loginData.token);
|
||||
|
||||
const { data: user2 } = await client2.user.self();
|
||||
|
||||
user2.item.groupName = user1.item.groupName;
|
||||
|
||||
// Cleanup User 2
|
||||
|
||||
const { response: deleteResp } = await client2.user.delete();
|
||||
expect(deleteResp.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
66
frontend/lib/api/__test__/user/group.test.ts
Normal file
66
frontend/lib/api/__test__/user/group.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { factories } from "../factories";
|
||||
import { sharedUserClient } from "../test-utils";
|
||||
|
||||
describe("first time user workflow (register, login, join group)", () => {
|
||||
test("user should be able to update group", async () => {
|
||||
const { client } = await factories.client.singleUse();
|
||||
|
||||
const name = faker.name.firstName();
|
||||
|
||||
const { response, data: group } = await client.group.update({
|
||||
name,
|
||||
currency: "eur",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(group.name).toBe(name);
|
||||
});
|
||||
|
||||
test("user should be able to get own group", async () => {
|
||||
const { client } = await factories.client.singleUse();
|
||||
|
||||
const { response, data: group } = await client.group.get();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(group.name).toBeTruthy();
|
||||
expect(group.currency).toBe("USD");
|
||||
});
|
||||
|
||||
test("user should be able to join create join token and have user signup", async () => {
|
||||
const api = factories.client.public();
|
||||
|
||||
// Setup User 1 Token
|
||||
const client = await sharedUserClient();
|
||||
const { data: user1 } = await client.user.self();
|
||||
|
||||
const { response, data } = await client.group.createInvitation({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
uses: 1,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.token).toBeTruthy();
|
||||
|
||||
// Create User 2 with token
|
||||
const duplicateUser = factories.user();
|
||||
duplicateUser.token = data.token;
|
||||
|
||||
const { response: registerResp } = await api.register(duplicateUser);
|
||||
expect(registerResp.status).toBe(204);
|
||||
|
||||
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
|
||||
expect(loginResp.status).toBe(200);
|
||||
|
||||
// Get Self and Assert
|
||||
const client2 = factories.client.user(loginData.token);
|
||||
const { data: user2 } = await client2.user.self();
|
||||
|
||||
user2.item.groupName = user1.item.groupName;
|
||||
|
||||
// Cleanup User 2
|
||||
const { response: deleteResp } = await client2.user.delete();
|
||||
expect(deleteResp.status).toBe(204);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseAPI, route } from "../base";
|
||||
import { GroupInvitation, GroupInvitationCreate } from "../types/data-contracts";
|
||||
import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
|
||||
|
||||
export class GroupApi extends BaseAPI {
|
||||
createInvitation(data: GroupInvitationCreate) {
|
||||
@@ -8,4 +8,17 @@ export class GroupApi extends BaseAPI {
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
update(data: GroupUpdate) {
|
||||
return this.http.put<GroupUpdate, Group>({
|
||||
url: route("/groups"),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.http.get<Group>({
|
||||
url: route("/groups"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@ export interface DocumentOut {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
createdAt: Date;
|
||||
currency: string;
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
currency: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ItemAttachment {
|
||||
createdAt: Date;
|
||||
document: DocumentOut;
|
||||
@@ -187,6 +200,12 @@ export interface LocationSummary {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface PaginationResultRepoItemSummary {
|
||||
items: ItemSummary[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserOut {
|
||||
email: string;
|
||||
|
||||
35
frontend/lib/data/currency.ts
Normal file
35
frontend/lib/data/currency.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type Codes = "USD" | "EUR" | "GBP" | "JPY";
|
||||
|
||||
export type Currency = {
|
||||
code: Codes;
|
||||
local: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const currencies: Currency[] = [
|
||||
{
|
||||
code: "USD",
|
||||
local: "en-US",
|
||||
symbol: "$",
|
||||
name: "US Dollar",
|
||||
},
|
||||
{
|
||||
code: "EUR",
|
||||
local: "de-DE",
|
||||
symbol: "€",
|
||||
name: "Euro",
|
||||
},
|
||||
{
|
||||
code: "GBP",
|
||||
local: "en-GB",
|
||||
symbol: "£",
|
||||
name: "British Pound",
|
||||
},
|
||||
{
|
||||
code: "JPY",
|
||||
local: "ja-JP",
|
||||
symbol: "¥",
|
||||
name: "Japanese Yen",
|
||||
},
|
||||
];
|
||||
150
frontend/lib/data/themes.ts
Normal file
150
frontend/lib/data/themes.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export type DaisyTheme =
|
||||
| "light"
|
||||
| "dark"
|
||||
| "cupcake"
|
||||
| "bumblebee"
|
||||
| "emerald"
|
||||
| "corporate"
|
||||
| "synthwave"
|
||||
| "retro"
|
||||
| "cyberpunk"
|
||||
| "valentine"
|
||||
| "halloween"
|
||||
| "garden"
|
||||
| "forest"
|
||||
| "aqua"
|
||||
| "lofi"
|
||||
| "pastel"
|
||||
| "fantasy"
|
||||
| "wireframe"
|
||||
| "black"
|
||||
| "luxury"
|
||||
| "dracula"
|
||||
| "cmyk"
|
||||
| "autumn"
|
||||
| "business"
|
||||
| "acid"
|
||||
| "lemonade"
|
||||
| "night"
|
||||
| "coffee"
|
||||
| "winter";
|
||||
|
||||
export type ThemeOption = {
|
||||
label: string;
|
||||
value: DaisyTheme;
|
||||
};
|
||||
|
||||
export const themes: ThemeOption[] = [
|
||||
{
|
||||
label: "Garden",
|
||||
value: "garden",
|
||||
},
|
||||
{
|
||||
label: "Light",
|
||||
value: "light",
|
||||
},
|
||||
{
|
||||
label: "Cupcake",
|
||||
value: "cupcake",
|
||||
},
|
||||
{
|
||||
label: "Bumblebee",
|
||||
value: "bumblebee",
|
||||
},
|
||||
{
|
||||
label: "Emerald",
|
||||
value: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Corporate",
|
||||
value: "corporate",
|
||||
},
|
||||
{
|
||||
label: "Synthwave",
|
||||
value: "synthwave",
|
||||
},
|
||||
{
|
||||
label: "Retro",
|
||||
value: "retro",
|
||||
},
|
||||
{
|
||||
label: "Cyberpunk",
|
||||
value: "cyberpunk",
|
||||
},
|
||||
{
|
||||
label: "Valentine",
|
||||
value: "valentine",
|
||||
},
|
||||
{
|
||||
label: "Halloween",
|
||||
value: "halloween",
|
||||
},
|
||||
{
|
||||
label: "Forest",
|
||||
value: "forest",
|
||||
},
|
||||
{
|
||||
label: "Aqua",
|
||||
value: "aqua",
|
||||
},
|
||||
{
|
||||
label: "Lofi",
|
||||
value: "lofi",
|
||||
},
|
||||
{
|
||||
label: "Pastel",
|
||||
value: "pastel",
|
||||
},
|
||||
{
|
||||
label: "Fantasy",
|
||||
value: "fantasy",
|
||||
},
|
||||
{
|
||||
label: "Wireframe",
|
||||
value: "wireframe",
|
||||
},
|
||||
{
|
||||
label: "Black",
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
label: "Luxury",
|
||||
value: "luxury",
|
||||
},
|
||||
{
|
||||
label: "Dracula",
|
||||
value: "dracula",
|
||||
},
|
||||
{
|
||||
label: "Cmyk",
|
||||
value: "cmyk",
|
||||
},
|
||||
{
|
||||
label: "Autumn",
|
||||
value: "autumn",
|
||||
},
|
||||
{
|
||||
label: "Business",
|
||||
value: "business",
|
||||
},
|
||||
{
|
||||
label: "Acid",
|
||||
value: "acid",
|
||||
},
|
||||
{
|
||||
label: "Lemonade",
|
||||
value: "lemonade",
|
||||
},
|
||||
{
|
||||
label: "Night",
|
||||
value: "night",
|
||||
},
|
||||
{
|
||||
label: "Coffee",
|
||||
value: "coffee",
|
||||
},
|
||||
{
|
||||
label: "Winter",
|
||||
value: "winter",
|
||||
},
|
||||
];
|
||||
15
frontend/middleware/auth.ts
Normal file
15
frontend/middleware/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAuthStore } from "~~/stores/auth";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuthStore();
|
||||
const api = useUserApi();
|
||||
|
||||
if (!auth.self) {
|
||||
const { data, error } = await api.user.self();
|
||||
if (error) {
|
||||
navigateTo("/");
|
||||
}
|
||||
|
||||
auth.$patch({ self: data.item });
|
||||
}
|
||||
});
|
||||
@@ -5,8 +5,9 @@
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Homebox | Home",
|
||||
});
|
||||
@@ -15,15 +16,6 @@
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (auth.self === null) {
|
||||
const { data, error } = await api.user.self();
|
||||
if (error) {
|
||||
navigateTo("/");
|
||||
}
|
||||
|
||||
auth.$patch({ self: data.item });
|
||||
}
|
||||
|
||||
const itemsStore = useItemStore();
|
||||
const items = computed(() => itemsStore.items);
|
||||
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
<a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank">
|
||||
<Icon name="mdi-twitter" class="h-8 w-8" />
|
||||
</a>
|
||||
<a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord">
|
||||
<a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord" target="_blank">
|
||||
<Icon name="mdi-discord" class="h-8 w-8" />
|
||||
</a>
|
||||
<a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs">
|
||||
<a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs" target="_blank">
|
||||
<Icon name="mdi-folder" class="h-8 w-8" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { capitalize } from "~~/lib/strings";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -118,12 +118,12 @@
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Purchased Price",
|
||||
label: "Purchase Price",
|
||||
ref: "purchasePrice",
|
||||
},
|
||||
{
|
||||
type: "date",
|
||||
label: "Purchased At",
|
||||
label: "Purchase Date",
|
||||
ref: "purchaseTime",
|
||||
},
|
||||
];
|
||||
@@ -233,6 +233,7 @@
|
||||
loading: false,
|
||||
|
||||
// Values
|
||||
obj: {},
|
||||
id: "",
|
||||
title: "",
|
||||
type: "",
|
||||
@@ -248,11 +249,13 @@
|
||||
editState.title = attachment.document.title;
|
||||
editState.type = attachment.type;
|
||||
editState.modal = true;
|
||||
|
||||
editState.obj = attachmentOpts.find(o => o.value === attachment.type);
|
||||
}
|
||||
|
||||
async function updateAttachment() {
|
||||
editState.loading = true;
|
||||
|
||||
console.log(editState.type);
|
||||
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
|
||||
title: editState.title,
|
||||
type: editState.type,
|
||||
@@ -282,7 +285,14 @@
|
||||
<template #title> Attachment Edit </template>
|
||||
|
||||
<FormTextField v-model="editState.title" label="Attachment Title" />
|
||||
<FormSelect v-model="editState.type" label="Attachment Type" value="value" name="text" :items="attachmentOpts" />
|
||||
<FormSelect
|
||||
v-model="editState.obj"
|
||||
v-model:value="editState.type"
|
||||
label="Attachment Type"
|
||||
value-key="value"
|
||||
name="text"
|
||||
:items="attachmentOpts"
|
||||
/>
|
||||
<div class="modal-action">
|
||||
<BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
|
||||
import { Detail, Details } from "~~/components/global/DetailsSection/types";
|
||||
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -76,7 +76,7 @@
|
||||
text: item.value?.serialNumber,
|
||||
},
|
||||
{
|
||||
name: "Mode Number",
|
||||
name: "Model Number",
|
||||
text: item.value?.modelNumber,
|
||||
},
|
||||
{
|
||||
@@ -145,7 +145,7 @@
|
||||
});
|
||||
|
||||
const warrantyDetails = computed(() => {
|
||||
const details: (Detail | DateDetail)[] = [
|
||||
const details: Details = [
|
||||
{
|
||||
name: "Lifetime Warranty",
|
||||
text: item.value?.lifetimeWarranty ? "Yes" : "No",
|
||||
@@ -180,15 +180,16 @@
|
||||
return item.value?.purchaseFrom || item.value?.purchasePrice !== "0";
|
||||
});
|
||||
|
||||
const purchaseDetails = computed<Array<Detail | DateDetail>>(() => {
|
||||
const purchaseDetails = computed<Details>(() => {
|
||||
return [
|
||||
{
|
||||
name: "Purchase From",
|
||||
name: "Purchased From",
|
||||
text: item.value?.purchaseFrom || "",
|
||||
},
|
||||
{
|
||||
name: "Purchase Price",
|
||||
text: item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
|
||||
text: item.value?.purchasePrice || "",
|
||||
type: "currency",
|
||||
},
|
||||
{
|
||||
name: "Purchase Date",
|
||||
@@ -205,7 +206,7 @@
|
||||
return item.value?.soldTo || item.value?.soldPrice !== "0";
|
||||
});
|
||||
|
||||
const soldDetails = computed<Array<Detail | DateDetail>>(() => {
|
||||
const soldDetails = computed<Details>(() => {
|
||||
return [
|
||||
{
|
||||
name: "Sold To",
|
||||
@@ -213,7 +214,8 @@
|
||||
},
|
||||
{
|
||||
name: "Sold Price",
|
||||
text: item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
|
||||
text: item.value?.soldPrice || "",
|
||||
type: "currency",
|
||||
},
|
||||
{
|
||||
name: "Sold At",
|
||||
@@ -325,17 +327,17 @@
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showPurchase">
|
||||
<template #title> Purchase </template>
|
||||
<template #title> Purchase Details </template>
|
||||
<DetailsSection :details="purchaseDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showWarranty">
|
||||
<template #title> Warranty </template>
|
||||
<template #title> Warranty Details </template>
|
||||
<DetailsSection :details="warrantyDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showSold">
|
||||
<template #title> Sold </template>
|
||||
<template #title> Sold Details </template>
|
||||
<DetailsSection :details="soldDetails" />
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const show = reactive({
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Homebox | Home",
|
||||
});
|
||||
@@ -75,7 +76,7 @@
|
||||
</template>
|
||||
<div class="px-4 pb-4">
|
||||
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" />
|
||||
<FormMultiselect v-model="selectedLocations" label="Labels" :items="locations ?? []" />
|
||||
<FormMultiselect v-model="selectedLocations" label="Locations" :items="locations ?? []" />
|
||||
</div>
|
||||
</BaseCard>
|
||||
<section class="mt-10">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
|
||||
import type { CustomDetail, Detail } from "~~/components/global/DetailsSection/types";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -23,7 +23,7 @@
|
||||
return data;
|
||||
});
|
||||
|
||||
const details = computed<(Detail | DateDetail)[]>(() => {
|
||||
const details = computed<(Detail | CustomDetail)[]>(() => {
|
||||
const details = [
|
||||
{
|
||||
name: "Name",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Detail, DateDetail } from "~~/components/global/DetailsSection/types";
|
||||
import { Detail, CustomDetail } from "~~/components/global/DetailsSection/types";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -23,7 +23,7 @@
|
||||
return data;
|
||||
});
|
||||
|
||||
const details = computed<(Detail | DateDetail)[]>(() => {
|
||||
const details = computed<(Detail | CustomDetail)[]>(() => {
|
||||
const details = [
|
||||
{
|
||||
name: "Name",
|
||||
|
||||
@@ -1,15 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { Detail } from "~~/components/global/DetailsSection/types";
|
||||
import { DaisyTheme } from "~~/composables/use-preferences";
|
||||
import { useAuthStore } from "~~/stores/auth";
|
||||
import { themes } from "~~/lib/data/themes";
|
||||
import { currencies, Currency } from "~~/lib/data/currency";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
middleware: ["auth"],
|
||||
});
|
||||
useHead({
|
||||
title: "Homebox | Profile",
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotifier();
|
||||
|
||||
// Currency Selection
|
||||
const currency = ref<Currency>(currencies[0]);
|
||||
|
||||
watch(currency, () => {
|
||||
if (group.value) {
|
||||
group.value.currency = currency.value.code;
|
||||
}
|
||||
|
||||
console.log(group.value);
|
||||
});
|
||||
|
||||
const currencyExample = computed(() => {
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.value ? currency.value.code : "USD",
|
||||
});
|
||||
|
||||
return formatter.format(1000);
|
||||
});
|
||||
|
||||
const { data: group } = useAsyncData(async () => {
|
||||
const { data } = await api.group.get();
|
||||
return data;
|
||||
});
|
||||
|
||||
// Sync Initial Currency
|
||||
watch(group, () => {
|
||||
if (group.value) {
|
||||
const found = currencies.find(c => c.code === group.value.currency);
|
||||
if (found) {
|
||||
currency.value = found;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function updateGroup() {
|
||||
const { data, error } = await api.group.update({
|
||||
name: group.value.name,
|
||||
currency: group.value.currency,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notify.error("Failed to update group");
|
||||
return;
|
||||
}
|
||||
|
||||
group.value = data;
|
||||
notify.success("Group updated");
|
||||
}
|
||||
|
||||
const pubApi = usePublicApi();
|
||||
const { data: status } = useAsyncData(async () => {
|
||||
const { data } = await pubApi.status();
|
||||
@@ -19,126 +74,6 @@
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
type ThemeOption = {
|
||||
label: string;
|
||||
value: DaisyTheme;
|
||||
};
|
||||
|
||||
const themes: ThemeOption[] = [
|
||||
{
|
||||
label: "Garden",
|
||||
value: "garden",
|
||||
},
|
||||
{
|
||||
label: "Light",
|
||||
value: "light",
|
||||
},
|
||||
{
|
||||
label: "Cupcake",
|
||||
value: "cupcake",
|
||||
},
|
||||
{
|
||||
label: "Bumblebee",
|
||||
value: "bumblebee",
|
||||
},
|
||||
{
|
||||
label: "Emerald",
|
||||
value: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Corporate",
|
||||
value: "corporate",
|
||||
},
|
||||
{
|
||||
label: "Synthwave",
|
||||
value: "synthwave",
|
||||
},
|
||||
{
|
||||
label: "Retro",
|
||||
value: "retro",
|
||||
},
|
||||
{
|
||||
label: "Cyberpunk",
|
||||
value: "cyberpunk",
|
||||
},
|
||||
{
|
||||
label: "Valentine",
|
||||
value: "valentine",
|
||||
},
|
||||
{
|
||||
label: "Halloween",
|
||||
value: "halloween",
|
||||
},
|
||||
{
|
||||
label: "Forest",
|
||||
value: "forest",
|
||||
},
|
||||
{
|
||||
label: "Aqua",
|
||||
value: "aqua",
|
||||
},
|
||||
{
|
||||
label: "Lofi",
|
||||
value: "lofi",
|
||||
},
|
||||
{
|
||||
label: "Pastel",
|
||||
value: "pastel",
|
||||
},
|
||||
{
|
||||
label: "Fantasy",
|
||||
value: "fantasy",
|
||||
},
|
||||
{
|
||||
label: "Wireframe",
|
||||
value: "wireframe",
|
||||
},
|
||||
{
|
||||
label: "Black",
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
label: "Luxury",
|
||||
value: "luxury",
|
||||
},
|
||||
{
|
||||
label: "Dracula",
|
||||
value: "dracula",
|
||||
},
|
||||
{
|
||||
label: "Cmyk",
|
||||
value: "cmyk",
|
||||
},
|
||||
{
|
||||
label: "Autumn",
|
||||
value: "autumn",
|
||||
},
|
||||
{
|
||||
label: "Business",
|
||||
value: "business",
|
||||
},
|
||||
{
|
||||
label: "Acid",
|
||||
value: "acid",
|
||||
},
|
||||
{
|
||||
label: "Lemonade",
|
||||
value: "lemonade",
|
||||
},
|
||||
{
|
||||
label: "Night",
|
||||
value: "night",
|
||||
},
|
||||
{
|
||||
label: "Coffee",
|
||||
value: "coffee",
|
||||
},
|
||||
{
|
||||
label: "Winter",
|
||||
value: "winter",
|
||||
},
|
||||
];
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const details = computed(() => {
|
||||
@@ -154,10 +89,6 @@
|
||||
] as Detail[];
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotifier();
|
||||
|
||||
async function deleteProfile() {
|
||||
const result = await confirm.open(
|
||||
"Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone."
|
||||
@@ -283,6 +214,27 @@
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard>
|
||||
<template #title>
|
||||
<BaseSectionHeader class="pb-0">
|
||||
<Icon name="mdi-accounts" class="mr-2 -mt-1 text-base-600" />
|
||||
<span class="text-base-600"> Group Settings </span>
|
||||
<template #description>
|
||||
Shared Group Settings. You may need to refresh your browser for some settings to apply.
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
</template>
|
||||
|
||||
<div v-if="group" class="p-5 pt-0">
|
||||
<FormSelect v-model="currency" label="Currency Format" :items="currencies" />
|
||||
<p class="m-2 text-sm">Example: {{ currencyExample }}</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<BaseButton @click="updateGroup"> Update Group </BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard>
|
||||
<template #title>
|
||||
<BaseSectionHeader>
|
||||
|
||||
Reference in New Issue
Block a user