1 Commits

Author SHA1 Message Date
Alexis
4ee412ee0c Added supabase and basic role management 2024-05-06 18:28:13 +02:00
23 changed files with 613 additions and 39 deletions

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import { RouterView } from 'vue-router'
import { useAuth } from './stores/AuthStore'
useDark()
useAuth()
</script>
<template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import { supabase } from '@/supabase/client'
import Button from '../ui/button/Button.vue'
async function handleGoogleLogin() {
supabase.auth.signInWithOAuth({
provider: 'google',
options: {
queryParams: {
access_type: 'offline',
prompt: 'consent'
}
}
})
}
</script>
<template>
<Button @click="handleGoogleLogin"> Google </Button>
</template>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import { useAuth } from '@/stores/AuthStore'
const { sessionToken } = useAuth()
</script>
<template>
<pre class="text-[.6em]">
{{ sessionToken }}
</pre>
</template>

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { cn } from '@/lib/utils'
import { areDatesIdentical, type LeimDate } from '@/models/Date'
import { Popover, PopoverTrigger } from '@/components/ui/popover'
import type { CalendarEvent } from '@/models/Events'
import { ref } from 'vue'
import CalendarEventDetails from './CalendarEventDetails.vue'
import { Popover, PopoverTrigger } from '@/components/ui/popover'
const props = defineProps<{
event: CalendarEvent

View File

@@ -4,12 +4,13 @@ import { useCalendar } from '@/stores/CalendarStore'
import { PhList, PhHouse, PhMagnifyingGlass } from '@phosphor-icons/vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import Button from '../ui/button/Button.vue'
import UserCTA from '../global/user/CTA.vue'
const { revealAdvancedSearch } = useCalendar()
</script>
<template>
<nav class="w-16 py-6 border-r-[1px] border-l-slate-500 grid justify-center">
<nav class="w-16 py-6 border-r-[1px] border-l-slate-500 grid grid-rows-[1fr_auto] justify-center">
<menu class="flex flex-col gap-4">
<li class="mb-12">
<Button variant="ghost" size="icon" @click="console.log" class="rounded-full">
@@ -52,5 +53,9 @@ const { revealAdvancedSearch } = useCalendar()
</TooltipProvider>
</li>
</menu>
<div>
<UserCTA />
</div>
</nav>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { supabase } from '@/supabase/client'
import { type Session } from '@supabase/supabase-js'
import { computed, onMounted, ref, watch } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { PhUserCircle } from '@phosphor-icons/vue'
import Button from '@/components/ui/button/Button.vue'
import MenuLoggedIn from './MenuLoggedIn.vue'
import MenuLoggedOut from './MenuLoggedOut.vue'
const session = ref<Session | null>()
const hasSession = computed(() => Boolean(session.value))
const menuOpened = ref<boolean>(false)
onMounted(() => {
supabase.auth.getSession().then(({ data }) => {
session.value = data.session
})
supabase.auth.onAuthStateChange((_, _session) => {
session.value = _session
})
})
watch(session, closeMenu)
function closeMenu() {
menuOpened.value = false
}
</script>
<template>
<Popover v-model:open="menuOpened">
<PopoverTrigger :as-child="hasSession">
<Avatar v-if="session" class="cursor-pointer">
<AvatarImage
:src="session?.user.user_metadata.avatar_url"
:alt="session?.user.user_metadata.full_name"
referrerpolicy="no-referrer"
/>
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<Button v-else variant="outline" size="icon">
<PhUserCircle size="18" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-fit p-0" :align="'start'" :side="'top'" :collision-padding="20">
<MenuLoggedIn v-if="session" :session />
<MenuLoggedOut v-else />
</PopoverContent>
</Popover>
</template>

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
import { supabase } from '@/supabase/client'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import type { Session } from '@supabase/supabase-js'
import { useRouter } from 'vue-router'
defineProps<{
session: Session
}>()
const router = useRouter()
async function handleLogout() {
try {
const { error } = await supabase.auth.signOut()
if (error) throw error
} catch (err) {
console.log(err)
}
}
function gotoProfilePage() {
router.push({ path: '/profil' })
}
</script>
<template>
<Command>
<CommandList>
<CommandGroup :heading="`Connecté en tant que ${session.user.user_metadata.email}`">
<CommandItem value="profile" @select="gotoProfilePage"> Profil </CommandItem>
<CommandItem value="logout" @select="handleLogout"> Déconnexion </CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { supabase } from '@/supabase/client'
import { Command, CommandItem, CommandList } from '@/components/ui/command'
import type { Session } from '@supabase/supabase-js'
defineProps<{
session?: Session | null
}>()
async function handleGoogleLogin() {
try {
supabase.auth.signInWithOAuth({
provider: 'google',
options: {
queryParams: {
access_type: 'offline',
prompt: 'consent'
}
}
})
} catch (err) {
console.log(err)
}
}
</script>
<template>
<Command>
<CommandList>
<CommandItem value="logout" @select="handleGoogleLogin"> Connexion </CommandItem>
</CommandList>
</Command>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AvatarRoot } from 'radix-vue'
import { type AvatarVariants, avatarVariant } from '.'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
shape?: AvatarVariants['shape']
}>(), {
size: 'sm',
shape: 'circle',
})
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AvatarFallback, type AvatarFallbackProps } from 'radix-vue'
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from 'radix-vue'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -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<typeof avatarVariant>

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '@/views/HomePage.vue'
import CalendarPage from '@/views/CalendarPage.vue'
import ProfilePage from '@/views/ProfilePage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -14,6 +15,11 @@ const router = createRouter({
path: '/calendrier',
name: 'calendrier',
component: CalendarPage
},
{
path: '/profil',
name: 'profil',
component: ProfilePage
}
]
})

32
src/stores/AuthStore.ts Normal file
View File

@@ -0,0 +1,32 @@
import { supabase } from '@/supabase/client'
import type { Session } from '@supabase/supabase-js'
import { jwtDecode, type JwtPayload } from 'jwt-decode'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
interface JwtPayloadExt extends JwtPayload {
user_role: string
}
export const useAuth = defineStore('auth', () => {
const session = ref<Session | null>()
const userRoles = computed(() => {
if (session.value) {
return (jwtDecode(session.value.access_token) as JwtPayloadExt).user_role
}
return ''
})
async function loadSession() {
return supabase.auth.getSession().then(({ data }) => {
session.value = data.session
})
}
supabase.auth.onAuthStateChange((_, _session) => {
session.value = _session
})
return { session, userRoles, loadSession }
})

View File

@@ -4,14 +4,14 @@ import type { CalendarEvent } from '@/models/Events'
import { defineStore } from 'pinia'
import { ref, watch, type Ref } from 'vue'
import { useCalendar } from './CalendarStore'
import { useAuth } from './AuthStore'
// import { useCharacters } from './CharacterStore'
export const useCalendarEvents = defineStore('calendar-events', () => {
const authStore = useAuth()
const { currentDate, currentConfig } = useCalendar()
// const { charactersWithBirthData, charactersWithDeathData } = useCharacters()
const baseEvents: CalendarEvent[] = initialEvents
// const characterBirthEvents = charactersWithBirthData.map((character) => {
// return {
// title: `Naissance de ${character.name}`,
@@ -28,9 +28,13 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
// } as CalendarEvent
// })
const allEvents = [...baseEvents].sort((a, b) => {
return compareDates(a.startDate, b.startDate, 'desc')
})
const allEvents: CalendarEvent[] = [...initialEvents]
.filter((event) => {
return !event.hidden || (event.hidden && ['sa', 'gm'].includes(authStore.userRoles))
})
.sort((a, b) => {
return compareDates(a.startDate, b.startDate, 'desc')
})
// Gets all current event in its default state
const currentEvents: Ref<CalendarEvent[]> = ref(computeCurrentEvents())
@@ -48,7 +52,7 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
* @param event The event to analyze
* @returns Whether the event should appear in the current view
*/
function shouldEventBeDisplayed(event: CalendarEvent): boolean {
function shouldEventBeDrawn(event: CalendarEvent): boolean {
const eventStartDateToDays = convertDateToDays(event.startDate)
const eventEndDateToDays: number = event.endDate ? convertDateToDays(event.endDate) : 0
@@ -105,7 +109,7 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
* @returns A list of events that can appear in the current view
*/
function computeCurrentEvents(): CalendarEvent[] {
return allEvents.filter((event) => shouldEventBeDisplayed(event))
return allEvents.filter((event) => shouldEventBeDrawn(event))
}
/**
@@ -168,27 +172,6 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
}
}
// // Special case : If we query the first one but it already is
// if (position === 'prev' && t[0].distance === 0) {
// const targetDate =
// t[0].targetKey === 'startDate' ? t[0].eventData.startDate : t[0].eventData.endDate!
// return {
// event: t[0].eventData,
// targetDate: targetDate
// }
// }
// // Special case : If we query the last one but it already is
// if (position === 'next' && t[t.length - 1].distance === 0) {
// const targetDate =
// t[t.length - 1].targetKey === 'startDate'
// ? t[t.length - 1].eventData.startDate
// : t[t.length - 1].eventData.endDate!
// return {
// event: t[t.length - 1].eventData,
// targetDate: targetDate
// }
// }
// Based on the direction, either ignore negative distance (past) or positive distance (future)
t = t.filter((i) => {
return position === 'next' ? i.distance > 0 : i.distance < 0

6
src/supabase/client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

View File

@@ -1,23 +1,24 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { useCalendar } from '@/stores/CalendarStore'
import { useTitle } from '@vueuse/core'
import { isDigit, isInt, isSignedInt } from '@/utils/Regex'
import { titleTemplate } from '@/utils/meta'
import { useTitle } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Calendar from '@/components/calendar/Calendar.vue'
import Sidebar from '@/components/calendar/Sidebar.vue'
import CalendarSearch from '@/components/calendar/search/CalendarSearch.vue'
import { useRoute, useRouter } from 'vue-router'
import { isDigit, isInt, isSignedInt } from '@/utils/Regex'
import { onBeforeMount } from 'vue'
useTitle('Calendrier', titleTemplate)
const router = useRouter()
const route = useRoute()
const { defaultDate, selectedDate, jumpToDate } = useCalendar()
const { isAdvancedSearchOpen } = storeToRefs(useCalendar())
const router = useRouter()
const route = useRoute()
// Check whether all the params are good to go before rendering anything
onBeforeMount(() => {
let isInvalid: boolean = false

25
src/views/ProfilePage.vue Normal file
View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import Profile from '@/components/auth/Profile.vue'
import Auth from '@/components/auth/Auth.vue'
import { supabase } from '@/supabase/client'
const session = ref()
onMounted(() => {
supabase.auth.getSession().then(({ data }) => {
session.value = data.session
})
supabase.auth.onAuthStateChange((_, _session) => {
session.value = _session
})
})
</script>
<template>
<div class="container" style="padding: 50px 0 100px 0">
<Profile v-if="session" :session="session" />
<Auth v-else />
</div>
</template>

4
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Supabase
.branches
.temp
.env

128
supabase/config.toml Normal file
View File

@@ -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"

View File

@@ -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;

View File

@@ -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)

1
supabase/seed.sql Normal file
View File

@@ -0,0 +1 @@
insert into public.role_permissions (role, permission) values ('gm', 'events.see.hidden');