diff --git a/cmd/install.go b/cmd/install.go index 80b1282a..c65409f9 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -16,8 +16,6 @@ import ( // install runs the first time setup of setting up the database. func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) { - consts := initConstants() - qMap := readQueries(queryFilePath, db, fs) fmt.Println("") @@ -63,6 +61,43 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo // Load the queries. q := prepareQueries(qMap, db, ko) + // Sample list. + defList, optinList := installLists(q) + + // Sample subscribers. + installSubs(defList, optinList, q) + + // Templates. + campTplID, archiveTplID := installTemplates(q) + + // Sample campaign. + installCampaign(campTplID, archiveTplID, q) + + // Super admin role. + installUser(q) + + lo.Printf("setup complete") + lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) +} + +// installSchema executes the SQL schema and creates the necessary tables and types. +func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { + q, err := fs.Read("/schema.sql") + if err != nil { + return err + } + + if _, err := db.Exec(string(q)); err != nil { + return err + } + + // Insert the current migration version. + return recordMigrationVersion(curVer, db) +} + +func installUser(q *models.Queries) { + consts := initConstants() + // Super admin role. perms := []string{} for p := range consts.Permissions { @@ -84,8 +119,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, "enabled"); err != nil { lo.Fatalf("error creating superadmin user: %v", err) } +} - // Sample list. +func installLists(q *models.Queries) (int, int) { var ( defList int optinList int @@ -111,13 +147,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating list: %v", err) } + return defList, optinList +} + +func installSubs(defListID, optinListID int, q *models.Queries) { // Sample subscriber. if _, err := q.UpsertSubscriber.Exec( uuid.Must(uuid.NewV4()), "john@example.com", "John Doe", `{"type": "known", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(defList)}, + pq.Int64Array{int64(defListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("Error creating subscriber: %v", err) @@ -127,12 +167,14 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo "anon@example.com", "Anon Doe", `{"type": "unknown", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(optinList)}, + pq.Int64Array{int64(optinListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("error creating subscriber: %v", err) } +} +func installTemplates(q *models.Queries) (int, int) { // Default campaign template. campTpl, err := fs.Get("/static/email-templates/default.tpl") if err != nil { @@ -158,6 +200,20 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating default campaign template: %v", err) } + // Sample tx template. + txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") + if err != nil { + lo.Fatalf("error reading default e-mail template: %v", err) + } + + if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating sample transactional template: %v", err) + } + + return campTplID, archiveTplID +} + +func installCampaign(campTplID, archiveTplID int, q *models.Queries) { // Sample campaign. if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()), models.CampaignTypeRegular, @@ -189,33 +245,6 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating sample campaign: %v", err) } - // Sample tx template. - txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") - if err != nil { - lo.Fatalf("error reading default e-mail template: %v", err) - } - - if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { - lo.Fatalf("error creating sample transactional template: %v", err) - } - - lo.Printf("setup complete") - lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) -} - -// installSchema executes the SQL schema and creates the necessary tables and types. -func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { - q, err := fs.Read("/schema.sql") - if err != nil { - return err - } - - if _, err := db.Exec(string(q)); err != nil { - return err - } - - // Insert the current migration version. - return recordMigrationVersion(curVer, db) } // recordMigrationVersion inserts the given version (of DB migration) into the diff --git a/cmd/roles.go b/cmd/roles.go index 39d87a0e..c7a820d7 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -69,7 +69,7 @@ func handleUpdateRole(c echo.Context) error { } // Validate. - r.Name = strings.TrimSpace(r.Name) + r.Name.String = strings.TrimSpace(r.Name.String) out, err := app.core.UpdateRole(id, r) if err != nil { @@ -99,7 +99,7 @@ func handleDeleteRole(c echo.Context) error { func validateRole(r models.Role, app *App) error { // Validate fields. - if !strHasLen(r.Name, 3, stdInputMaxLen) { + if !strHasLen(r.Name.String, 2, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) } @@ -109,5 +109,13 @@ func validateRole(r models.Role, app *App) error { } } + for _, l := range r.Lists { + for _, p := range l.Permissions { + if p != "list:get" && p != "list:manage" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "list permissions")) + } + } + } + return nil } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 041b9bd1..5eb0e6d1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -168,7 +168,7 @@ export default Vue.extend({ mounted() { // Lists is required across different views. On app load, fetch the lists // and have them in the store. - if (this.$can('lists:get')) { + if (this.$can('lists:get_all')) { this.$api.getLists({ minimal: true, per_page: 'all' }); } diff --git a/frontend/src/components/Navigation.vue b/frontend/src/components/Navigation.vue index f6546be6..5f1efd33 100644 --- a/frontend/src/components/Navigation.vue +++ b/frontend/src/components/Navigation.vue @@ -3,7 +3,7 @@ - {{ $t('globals.buttons.close') }} - {{ $t('globals.buttons.save') }} diff --git a/frontend/src/views/Lists.vue b/frontend/src/views/Lists.vue index d47222f7..a8467397 100644 --- a/frontend/src/views/Lists.vue +++ b/frontend/src/views/Lists.vue @@ -8,7 +8,7 @@
- + {{ $t('globals.buttons.new') }} diff --git a/frontend/src/views/RoleForm.vue b/frontend/src/views/RoleForm.vue index 151cc24d..9b364557 100644 --- a/frontend/src/views/RoleForm.vue +++ b/frontend/src/views/RoleForm.vue @@ -18,23 +18,86 @@ -

- {{ $t('globals.buttons.toggleSelect') }} -

+
+
{{ $t('users.listPerms') }}
+
+
+
+ + + - - - {{ $tc(`globals.terms.${props.row.group}`) }} - - - -
- - {{ p }} - +
+
+ {{ + $t('globals.buttons.add') + }} +
- - + + + {{ $t('users.listPermsWarning') }} + +
+ + + + + {{ props.row.name }} + + + + + + {{ $t('globals.buttons.view') }} + + + {{ $t('globals.buttons.manage') }} + + + + + + + + + + + +
+ +
+
+
+
{{ $t('users.perms') }}
+
+ +
+ + + + {{ $tc(`globals.terms.${props.row.group}`) }} + + + +
+ + {{ p }} + +
+
+
+
{{ $t('globals.buttons.learnMore') }} @@ -73,7 +136,9 @@ export default Vue.extend({ return { // Binds form input values. form: { - name: '', + curList: null, + lists: [], + name: this.$t('users.newRole'), permissions: {}, }, hasToggle: false, @@ -82,6 +147,18 @@ export default Vue.extend({ }, methods: { + onAddListPerm() { + const list = this.lists.results.find((l) => l.id === this.form.curList); + this.form.lists.push({ id: list.id, name: list.name, permissions: ["list:get", "list:manage"] }); + + this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null; + }, + + onDeleteListPerm(id) { + this.form.lists = this.form.lists.filter((p) => p.id !== id); + this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null; + }, + onSubmit() { if (this.isEditing) { this.updateRole(); @@ -93,21 +170,26 @@ export default Vue.extend({ onToggleSelect() { if (this.hasToggle) { - this.form.permissions = {}; + this.form.permissions = []; } else { this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => { item.permissions.forEach((p) => { - acc[p] = true; + acc.push(p); }); return acc; - }, {}); + }, []); } this.hasToggle = !this.hasToggle; }, createRole() { - const form = { ...this.form, permissions: Object.keys(this.form.permissions) }; + const lists = this.form.lists.reduce((acc, item) => { + acc.push({ id: item.id, permissions: item.permissions }) + return acc; + }, []); + + const form = { name: this.form.name, permissions: this.form.permissions, lists: lists }; this.$api.createRole(form).then((data) => { this.$emit('finished'); this.$utils.toast(this.$t('globals.messages.created', { name: data.name })); @@ -116,9 +198,12 @@ export default Vue.extend({ }, updateRole() { - const form = { - id: this.data.id, name: this.form.name, permissions: Object.keys(this.form.permissions).filter((key) => this.form.permissions[key] === true), - }; + const lists = this.form.lists.reduce((acc, item) => { + acc.push({ id: item.id, permissions: item.permissions }) + return acc; + }, []); + + const form = { id: this.$props.data.id, name: this.form.name, permissions: this.form.permissions, lists: lists }; this.$api.updateRole(form).then((data) => { this.$emit('finished'); this.$parent.close(); @@ -128,17 +213,23 @@ export default Vue.extend({ }, computed: { - ...mapState(['loading', 'serverConfig']), + ...mapState(['loading', 'serverConfig', 'lists']), + + // Return the list of unselected lists. + filteredLists() { + if (!this.lists.results) { + return []; + } + + const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {}); + return this.lists.results.filter((l) => (!(l.id in subIDs))); + }, + }, mounted() { - this.form = { ...this.form, name: this.$props.data.name }; - if (this.isEditing) { - this.form.permissions = this.$props.data.permissions.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {}); + this.form = { ...this.form, ...this.$props.data }; // It's the superadmin role. Disable the form. if (this.$props.data.id === 1 || !this.$can('roles:manage')) { @@ -151,15 +242,18 @@ export default Vue.extend({ return acc; } item.permissions.forEach((p) => { - if (p !== 'subscribers:sql_query') { - acc[p] = true; + if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) { + acc.push(p); } }); return acc; - }, {}); + }, []); } this.$nextTick(() => { + if (this.filteredLists.length > 0) { + this.form.curList = this.filteredLists[0].id; + } this.$refs.focus.focus(); }); }, diff --git a/frontend/src/views/Roles.vue b/frontend/src/views/Roles.vue index 0b10bacb..6d27643e 100644 --- a/frontend/src/views/Roles.vue +++ b/frontend/src/views/Roles.vue @@ -125,8 +125,9 @@ export default Vue.extend({ }, onCloneRole(name, item) { - this.$api.createRole({ name, permissions: item.permissions }).then(() => { + this.$api.createRole({ name, permissions: item.permissions, lists: item.lists }).then(() => { this.$api.getRoles(); + this.$utils.toast(this.$t('globals.messages.created', { name })); }); }, diff --git a/i18n/en.json b/i18n/en.json index 048391ac..946d617e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -152,6 +152,7 @@ "globals.buttons.save": "Save", "globals.buttons.saveChanges": "Save changes", "globals.buttons.view": "View", + "globals.buttons.manage": "Manage", "globals.days.0": "Sun", "globals.days.1": "Sun", "globals.days.2": "Mon", @@ -600,6 +601,10 @@ "users.role": "Role | Roles", "users.roles": "Roles", "users.newRole": "New role", + "users.listPerms": "List permissions", + "users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions", + "users.perms": "Permissions", + "users.roleGroup": "Group", "users.loginOIDC": "Login with {name}", "users.logout": "Logout", "users.profile": "Profile", diff --git a/internal/core/roles.go b/internal/core/roles.go index 15838522..29dd9dac 100644 --- a/internal/core/roles.go +++ b/internal/core/roles.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "net/http" "github.com/knadh/listmonk/models" @@ -16,6 +17,17 @@ func (c *Core) GetRoles() ([]models.Role, error) { c.i18n.Ts("globals.messages.errorFetching", "name", "{users.roles}", "error", pqErrMsg(err))) } + // Unmarshall the nested list permissions, if any. + for n, r := range out { + if r.ListsRaw == nil { + continue + } + + if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil { + c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err) + } + } + return out, nil } @@ -28,9 +40,55 @@ func (c *Core) CreateRole(r models.Role) (models.Role, error) { c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) } + if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil { + return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) + } + return out, nil } +// UpsertListPermissions upserts permission for a role. +func (c *Core) UpsertListPermissions(roleID int, lp []models.ListPermission) error { + var ( + listIDs = make([]int, 0, len(lp)) + listPerms = make([][]string, 0, len(lp)) + ) + for _, p := range lp { + if len(p.Permissions) == 0 { + continue + } + + listIDs = append(listIDs, p.ID) + + // For the Postgres array unnesting query to work, all permissions arrays should + // have equal number of entries. Add "" in case there's only one of either list:get or list:manage + perms := make([]string, 2) + copy(perms[:], p.Permissions[:]) + listPerms = append(listPerms, perms) + } + + if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteListPermission deletes a list permission entry from a role. +func (c *Core) DeleteListPermission(roleID, listID int) error { + if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" { + return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole")) + } + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err))) + } + + return nil +} + // UpdateRole updates a given role. func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) { var out models.Role @@ -44,6 +102,11 @@ func (c *Core) UpdateRole(id int, r models.Role) (models.Role, error) { return models.Role{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.role}")) } + if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil { + return models.Role{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err))) + } + return out, nil } diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go index a74f6d9c..363ad2b4 100644 --- a/internal/migrations/v3.1.0.go +++ b/internal/migrations/v3.1.0.go @@ -41,12 +41,15 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger CREATE TABLE IF NOT EXISTS user_roles ( id SERIAL PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', + parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE, + list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE, permissions TEXT[] NOT NULL DEFAULT '{}', + name TEXT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON user_roles(LOWER(name)); + CREATE UNIQUE INDEX IF NOT EXISTS user_roles_idx ON user_roles (parent_id, list_id); + CREATE UNIQUE INDEX IF NOT EXISTS user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL; CREATE TABLE IF NOT EXISTS sessions ( id TEXT NOT NULL PRIMARY KEY, diff --git a/models/models.go b/models/models.go index d9969127..23188d28 100644 --- a/models/models.go +++ b/models/models.go @@ -168,11 +168,22 @@ type User struct { HasPassword bool `db:"-" json:"-"` } +type ListPermission struct { + ID int `json:"id"` + Name string `json:"name"` + Permissions pq.StringArray `json:"permissions"` +} + type Role struct { Base - Name string `db:"name" json:"name"` + Name null.String `db:"name" json:"name"` Permissions pq.StringArray `db:"permissions" json:"permissions"` + + ListID null.Int `db:"list_id" json:"-"` + ParentID null.Int `db:"parent_id" json:"-"` + ListsRaw json.RawMessage `db:"list_permissions" json:"-"` + Lists []ListPermission `db:"-" json:"lists"` } // Subscriber represents an e-mail subscriber. diff --git a/models/queries.go b/models/queries.go index a079a299..2a52478d 100644 --- a/models/queries.go +++ b/models/queries.go @@ -117,10 +117,12 @@ type Queries struct { GetAPITokens *sqlx.Stmt `query:"get-api-tokens"` LoginUser *sqlx.Stmt `query:"login-user"` - CreateRole *sqlx.Stmt `query:"create-role"` - GetRoles *sqlx.Stmt `query:"get-roles"` - UpdateRole *sqlx.Stmt `query:"update-role"` - DeleteRole *sqlx.Stmt `query:"delete-role"` + CreateRole *sqlx.Stmt `query:"create-role"` + GetRoles *sqlx.Stmt `query:"get-roles"` + UpdateRole *sqlx.Stmt `query:"update-role"` + DeleteRole *sqlx.Stmt `query:"delete-role"` + UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"` + DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"` } // CompileSubscriberQueryTpl takes an arbitrary WHERE expressions diff --git a/permissions.json b/permissions.json index 7f0e834d..52eecfbd 100644 --- a/permissions.json +++ b/permissions.json @@ -3,8 +3,8 @@ "group": "lists", "permissions": [ - "lists:get", - "lists:manage" + "lists:get_all", + "lists:manage_all" ] }, { diff --git a/queries.sql b/queries.sql index 8ab5058e..8188b3fd 100644 --- a/queries.sql +++ b/queries.sql @@ -1096,13 +1096,39 @@ UPDATE users SET name=$2, email=$3, WHERE id=$1; -- name: get-roles -SELECT * FROM user_roles ORDER BY created_at; +WITH mainroles AS ( + SELECT ur.* FROM user_roles ur WHERE ur.parent_id IS NULL +), +listroles AS ( + SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms + FROM user_roles ur + LEFT JOIN lists ON(lists.id = ur.list_id) + WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id +) +SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p + LEFT JOIN listroles l ON p.id = l.parent_id; -- name: create-role INSERT INTO user_roles (name, permissions, created_at, updated_at) VALUES($1, $2, NOW(), NOW()) RETURNING *; +-- name: upsert-list-permissions +WITH d AS ( + -- Delete lists that aren't included. + DELETE FROM user_roles WHERE parent_id = $1 AND list_id != ALL($2::INT[]) +), +p AS ( + -- Get (list_id, perms[]), (list_id, perms[]) + SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms +) +INSERT INTO user_roles (parent_id, list_id, permissions) + SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), '') FROM p + ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions; + +-- name: delete-list-permission +DELETE FROM user_roles WHERE parent_id=$1 AND list_id=$2; + -- name: update-role -UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 RETURNING *; +UPDATE user_roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *; -- name: delete-role DELETE FROM user_roles WHERE id=$1; diff --git a/schema.sql b/schema.sql index 99500899..f0433d19 100644 --- a/schema.sql +++ b/schema.sql @@ -304,12 +304,15 @@ DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces( DROP TABLE IF EXISTS user_roles CASCADE; CREATE TABLE user_roles ( id SERIAL PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', + parent_id INTEGER NULL REFERENCES user_roles(id) ON DELETE CASCADE ON UPDATE CASCADE, + list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE, permissions TEXT[] NOT NULL DEFAULT '{}', + name TEXT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -DROP INDEX IF EXISTS idx_roles_name; CREATE UNIQUE INDEX idx_roles_name ON user_roles(LOWER(name)); +CREATE UNIQUE INDEX user_roles_idx ON user_roles (parent_id, list_id); +CREATE UNIQUE INDEX user_roles_name_idx ON user_roles (name) WHERE name IS NOT NULL; -- users DROP TABLE IF EXISTS users CASCADE; @@ -326,8 +329,6 @@ CREATE TABLE users ( loggedin_at TIMESTAMP WITH TIME ZONE NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - - -- CONSTRAINT user_role_id FOREIGN KEY (role_id) REFERENCES user_roles (id) ON DELETE RESTRICT ); -- user sessions