+
+
+
+
diff --git a/components/ui/avatar/Avatar.vue b/components/ui/avatar/Avatar.vue
new file mode 100644
index 0000000..5b7b9fb
--- /dev/null
+++ b/components/ui/avatar/Avatar.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/components/ui/avatar/AvatarFallback.vue b/components/ui/avatar/AvatarFallback.vue
new file mode 100644
index 0000000..a671a21
--- /dev/null
+++ b/components/ui/avatar/AvatarFallback.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/components/ui/avatar/AvatarImage.vue b/components/ui/avatar/AvatarImage.vue
new file mode 100644
index 0000000..43499fa
--- /dev/null
+++ b/components/ui/avatar/AvatarImage.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/components/ui/avatar/index.ts b/components/ui/avatar/index.ts
new file mode 100644
index 0000000..c4af1a6
--- /dev/null
+++ b/components/ui/avatar/index.ts
@@ -0,0 +1,24 @@
+import { type VariantProps, cva } from 'class-variance-authority'
+
+export { default as Avatar } from './Avatar.vue'
+export { default as AvatarImage } from './AvatarImage.vue'
+export { default as AvatarFallback } from './AvatarFallback.vue'
+
+export const avatarVariant = cva(
+ 'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
+ {
+ variants: {
+ size: {
+ sm: 'h-10 w-10 text-xs',
+ base: 'h-16 w-16 text-2xl',
+ lg: 'h-32 w-32 text-5xl',
+ },
+ shape: {
+ circle: 'rounded-full',
+ square: 'rounded-md',
+ },
+ },
+ },
+)
+
+export type AvatarVariants = VariantProps
diff --git a/components/ui/command/index.ts b/components/ui/command/index.ts
index 0e35f4b..95d4b5f 100644
--- a/components/ui/command/index.ts
+++ b/components/ui/command/index.ts
@@ -1,3 +1,5 @@
+export { ComboboxPortal } from 'radix-vue'
+
export { default as Command } from './Command.vue'
export { default as CommandDialog } from './CommandDialog.vue'
export { default as CommandEmpty } from './CommandEmpty.vue'
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 16b5fee..04956d5 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -3,12 +3,13 @@ export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
- '@nuxtjs/tailwindcss',
- 'shadcn-nuxt',
- '@nuxtjs/color-mode',
+ '@nuxtjs/supabase',
'@pinia/nuxt',
+ '@vueuse/nuxt',
+ '@nuxtjs/tailwindcss',
+ '@nuxtjs/color-mode',
'@nuxt/eslint',
- '@vueuse/nuxt'
+ 'shadcn-nuxt'
],
css: ['~/assets/main.css'],
@@ -27,5 +28,8 @@ export default defineNuxtConfig({
preference: 'dark',
fallback: 'dark',
},
+ supabase: {
+ redirect: false
+ },
eslint: {}
})
diff --git a/package.json b/package.json
index a7c7372..9f01aad 100644
--- a/package.json
+++ b/package.json
@@ -14,10 +14,10 @@
},
"dependencies": {
"@nuxt/eslint": "^0.3.12",
- "@vueuse/nuxt": "^10.9.0",
"@phosphor-icons/vue": "^2.2.1",
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
+ "@vueuse/nuxt": "^10.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.364.0",
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.4.1",
+ "@nuxtjs/supabase": "^1.2.2",
"@nuxtjs/tailwindcss": "^6.12.0",
"@stylistic/eslint-plugin-js": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3bfdd6b..d05267c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,9 @@ importers:
'@nuxtjs/color-mode':
specifier: ^3.4.1
version: 3.4.1(rollup@4.17.2)
+ '@nuxtjs/supabase':
+ specifier: ^1.2.2
+ version: 1.2.2(rollup@4.17.2)
'@nuxtjs/tailwindcss':
specifier: ^6.12.0
version: 6.12.0(rollup@4.17.2)
@@ -705,6 +708,9 @@ packages:
'@nuxtjs/color-mode@3.4.1':
resolution: {integrity: sha512-vZgJqDstxInGw3RGSWbLoCLXtU1mvh1LLeuEA/X3a++DYA4ifwSbNoiSiOyb9qZHFEwz1Xr99H71sXV4IhOaEg==}
+ '@nuxtjs/supabase@1.2.2':
+ resolution: {integrity: sha512-DNEuB7YXaOhV118bNway6SUpQLGq0zsK1kzddyETrwx3dHz+7etEw+QKU7iVs/GZog9Q7xkUQtGhcEQYaTZ71w==}
+
'@nuxtjs/tailwindcss@6.12.0':
resolution: {integrity: sha512-vXvEq8z177TQcx0tc10mw3O6T9WeN0iTL8hIKGDfidmr+HKReexJU01aPgHefFrCu4LJB70egYFYnywzB9lMyQ==}
@@ -1112,6 +1118,28 @@ packages:
peerDependencies:
eslint: '>=8.40.0'
+ '@supabase/auth-js@2.64.1':
+ resolution: {integrity: sha512-tA2PXLoWEzhD0N1Vysree+HftfeWBbFV0E+taND5rj/pZTjkwKq/9GlrnXkbs5pnw+tsnABDRo2WLZmymihGdA==}
+
+ '@supabase/functions-js@2.3.1':
+ resolution: {integrity: sha512-QyzNle/rVzlOi4BbVqxLSH828VdGY1RElqGFAj+XeVypj6+PVtMlD21G8SDnsPQDtlqqTtoGRgdMlQZih5hTuw==}
+
+ '@supabase/node-fetch@2.6.15':
+ resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
+ engines: {node: 4.x || >=6.0.0}
+
+ '@supabase/postgrest-js@1.15.2':
+ resolution: {integrity: sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ==}
+
+ '@supabase/realtime-js@2.9.5':
+ resolution: {integrity: sha512-TEHlGwNGGmKPdeMtca1lFTYCedrhTAv3nZVoSjrKQ+wkMmaERuCe57zkC5KSWFzLYkb5FVHW8Hrr+PX1DDwplQ==}
+
+ '@supabase/storage-js@2.5.5':
+ resolution: {integrity: sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==}
+
+ '@supabase/supabase-js@2.43.0':
+ resolution: {integrity: sha512-e/MXc/7HPEXayWoxDTv+xqxF5+wlpH3/+UoP3TXLH0OiShICTbyJfmRgn/GCNw7E7+DlUq+3c48q12FtGWrVmQ==}
+
'@swc/helpers@0.5.11':
resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==}
@@ -1153,6 +1181,9 @@ packages:
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+ '@types/phoenix@1.6.4':
+ resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==}
+
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -1162,6 +1193,9 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+ '@types/ws@8.5.10':
+ resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
+
'@typescript-eslint/eslint-plugin@7.8.0':
resolution: {integrity: sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg==}
engines: {node: ^18.18.0 || >=20.0.0}
@@ -5600,6 +5634,18 @@ snapshots:
- rollup
- supports-color
+ '@nuxtjs/supabase@1.2.2(rollup@4.17.2)':
+ dependencies:
+ '@nuxt/kit': 3.11.2(rollup@4.17.2)
+ '@supabase/supabase-js': 2.43.0
+ defu: 6.1.4
+ pathe: 1.1.2
+ transitivePeerDependencies:
+ - bufferutil
+ - rollup
+ - supports-color
+ - utf-8-validate
+
'@nuxtjs/tailwindcss@6.12.0(rollup@4.17.2)':
dependencies:
'@nuxt/kit': 3.11.2(rollup@4.17.2)
@@ -5987,6 +6033,48 @@ snapshots:
- supports-color
- typescript
+ '@supabase/auth-js@2.64.1':
+ dependencies:
+ '@supabase/node-fetch': 2.6.15
+
+ '@supabase/functions-js@2.3.1':
+ dependencies:
+ '@supabase/node-fetch': 2.6.15
+
+ '@supabase/node-fetch@2.6.15':
+ dependencies:
+ whatwg-url: 5.0.0
+
+ '@supabase/postgrest-js@1.15.2':
+ dependencies:
+ '@supabase/node-fetch': 2.6.15
+
+ '@supabase/realtime-js@2.9.5':
+ dependencies:
+ '@supabase/node-fetch': 2.6.15
+ '@types/phoenix': 1.6.4
+ '@types/ws': 8.5.10
+ ws: 8.17.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@supabase/storage-js@2.5.5':
+ dependencies:
+ '@supabase/node-fetch': 2.6.15
+
+ '@supabase/supabase-js@2.43.0':
+ dependencies:
+ '@supabase/auth-js': 2.64.1
+ '@supabase/functions-js': 2.3.1
+ '@supabase/node-fetch': 2.6.15
+ '@supabase/postgrest-js': 1.15.2
+ '@supabase/realtime-js': 2.9.5
+ '@supabase/storage-js': 2.5.5
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
'@swc/helpers@0.5.11':
dependencies:
tslib: 2.6.2
@@ -6026,12 +6114,18 @@ snapshots:
'@types/normalize-package-data@2.4.4': {}
+ '@types/phoenix@1.6.4': {}
+
'@types/resolve@1.20.2': {}
'@types/semver@7.5.8': {}
'@types/web-bluetooth@0.0.20': {}
+ '@types/ws@8.5.10':
+ dependencies:
+ '@types/node': 20.12.11
+
'@typescript-eslint/eslint-plugin@7.8.0(@typescript-eslint/parser@7.8.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)':
dependencies:
'@eslint-community/regexpp': 4.10.0
diff --git a/supabase/config.toml b/supabase/config.toml
new file mode 100644
index 0000000..c9aa090
--- /dev/null
+++ b/supabase/config.toml
@@ -0,0 +1,128 @@
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "leim-tools"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` is always included.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request. `public` is always included.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 15
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+[storage.image_transformation]
+enabled = true
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://localhost:5173/"
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["http://localhost:5173/"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = false
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = false
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.google]
+enabled = true
+client_id = "env(GOOGLE_AUTH_ID)"
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(GOOGLE_AUTH_SECRET)"
+# Overrides the default auth redirectUrl.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+
+[auth.hook.custom_access_token]
+enabled = true
+uri = "pg-functions://postgres/public/custom_access_token_hook"
diff --git a/supabase/migrations/202401_init.sql b/supabase/migrations/202401_init.sql
new file mode 100644
index 0000000..f12fe1c
--- /dev/null
+++ b/supabase/migrations/202401_init.sql
@@ -0,0 +1,103 @@
+--
+-- For use with https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone
+--
+
+-- Custom types
+create type public.app_permission as enum ('events.see.hidden');
+create type public.app_role as enum ('sa', 'gm');
+
+-- USERS
+create table public.users (
+ id uuid references auth.users not null primary key, -- UUID from auth.users
+ username text
+);
+comment on table public.users is 'Profile data for each user.';
+comment on column public.users.id is 'References the internal Supabase Auth user.';
+
+-- USER ROLES
+create table public.user_roles (
+ id bigint generated by default as identity primary key,
+ user_id uuid references public.users on delete cascade not null,
+ role app_role not null,
+ unique (user_id, role)
+);
+comment on table public.user_roles is 'Application roles for each user.';
+
+-- ROLE PERMISSIONS
+create table public.role_permissions (
+ id bigint generated by default as identity primary key,
+ role app_role not null,
+ permission app_permission not null,
+ unique (role, permission)
+);
+comment on table public.role_permissions is 'Application permissions for each role.';
+
+-- authorize with role-based access control (RBAC)
+create function public.authorize(
+ requested_permission app_permission
+)
+returns boolean as $$
+declare
+ bind_permissions int;
+begin
+ select count(*)
+ from public.role_permissions
+ where role_permissions.permission = authorize.requested_permission
+ and role_permissions.role = (auth.jwt() ->> 'user_role')::public.app_role
+ into bind_permissions;
+
+ return bind_permissions > 0;
+end;
+$$ language plpgsql security definer set search_path = public;
+
+-- Secure the tables
+alter table public.users enable row level security;
+alter table public.user_roles enable row level security;
+alter table public.role_permissions enable row level security;
+create policy "Allow logged-in read access" on public.users for select using ( auth.role() = 'authenticated' );
+create policy "Allow individual insert access" on public.users for insert with check ( auth.uid() = id );
+create policy "Allow individual update access" on public.users for update using ( auth.uid() = id );
+create policy "Allow individual read access" on public.user_roles for select using ( auth.uid() = user_id );
+
+-- Send "previous data" on change
+alter table public.users replica identity full;
+
+-- inserts a row into public.users and assigns roles
+create function public.handle_new_user()
+returns trigger as $$
+declare is_admin boolean;
+begin
+ insert into public.users (id, username)
+ values (new.id, new.email);
+
+ return new;
+end;
+$$ language plpgsql security definer set search_path = auth, public;
+
+-- trigger the function every time a user is created
+create trigger on_auth_user_created
+ after insert on auth.users
+ for each row execute procedure public.handle_new_user();
+
+/**
+ * HELPER FUNCTIONS
+ * Create test user helper method.
+ */
+create or replace function public.create_user(
+ email text
+) returns uuid
+ security definer
+ set search_path = auth
+as $$
+ declare
+ user_id uuid;
+begin
+ user_id := extensions.uuid_generate_v4();
+
+ insert into auth.users (id, email)
+ values (user_id, email)
+ returning id into user_id;
+
+ return user_id;
+end;
+$$ language plpgsql;
diff --git a/supabase/migrations/202402_auth_hook.sql b/supabase/migrations/202402_auth_hook.sql
new file mode 100644
index 0000000..2d6ef1e
--- /dev/null
+++ b/supabase/migrations/202402_auth_hook.sql
@@ -0,0 +1,58 @@
+/**
+ * AUTH HOOKS
+ * Create an auth hook to add a custom claim to the access token jwt.
+ */
+
+-- Create the auth hook function
+-- https://supabase.com/docs/guides/auth/auth-hooks#hook-custom-access-token
+create or replace function public.custom_access_token_hook(event jsonb)
+returns jsonb
+language plpgsql
+stable
+as $$
+ declare
+ claims jsonb;
+ user_role public.app_role;
+ begin
+ -- Check if the user is marked as admin in the profiles table
+ select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;
+
+ claims := event->'claims';
+
+ if user_role is not null then
+ -- Set the claim
+ claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
+ else
+ claims := jsonb_set(claims, '{user_role}', 'null');
+ end if;
+
+ -- Update the 'claims' object in the original event
+ event := jsonb_set(event, '{claims}', claims);
+
+ -- Return the modified or original event
+ return event;
+ end;
+$$;
+
+grant usage on schema public to supabase_auth_admin;
+
+grant execute
+ on function public.custom_access_token_hook
+ to supabase_auth_admin;
+
+revoke execute
+ on function public.custom_access_token_hook
+ from authenticated, anon;
+
+grant all
+ on table public.user_roles
+to supabase_auth_admin;
+
+revoke all
+ on table public.user_roles
+ from authenticated, anon;
+
+create policy "Allow auth admin to read user roles" ON public.user_roles
+as permissive for select
+to supabase_auth_admin
+using (true)
diff --git a/supabase/seed.sql b/supabase/seed.sql
new file mode 100644
index 0000000..c4008aa
--- /dev/null
+++ b/supabase/seed.sql
@@ -0,0 +1 @@
+insert into public.role_permissions (role, permission) values ('gm', 'events.see.hidden');