From eaf77dce7f15730d0e668246d3d1eb7ec19d89e6 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 17 Nov 2023 16:24:39 -0800 Subject: [PATCH 01/50] Add mostly-working login --- .../controllers/auth_controller.ex | 9 +- server/lib/orcasite_web/router.ex | 29 +++-- ui/src/graphql/client.ts | 14 ++- ui/src/graphql/generated/index.ts | 101 +++++++++++++++++- ui/src/styles/theme.ts | 16 ++- 5 files changed, 142 insertions(+), 27 deletions(-) diff --git a/server/lib/orcasite_web/controllers/auth_controller.ex b/server/lib/orcasite_web/controllers/auth_controller.ex index 21e05d44..42130f7c 100644 --- a/server/lib/orcasite_web/controllers/auth_controller.ex +++ b/server/lib/orcasite_web/controllers/auth_controller.ex @@ -11,7 +11,14 @@ defmodule OrcasiteWeb.AuthController do end def success(conn, _activity, user, _token) do - return_to = get_session(conn, :return_to) || ~p"/" + return_to = + case user do + %{admin: true} -> + ~p"/admin" + + _ -> + get_session(conn, :return_to) || ~p"/" + end conn |> delete_session(:return_to) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 4ffdcaed..fc1b8296 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -33,7 +33,6 @@ defmodule OrcasiteWeb.Router do end pipeline :require_admin do - plug :check_authed plug :check_admin_path end @@ -97,6 +96,13 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through [:browser, :require_admin] live_dashboard "/admin/dashboard", metrics: OrcasiteWeb.Telemetry + + sign_in_route( + path: "/admin/sign-in", + overrides: [OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] + ) + + sign_out_route OrcasiteWeb.AuthController, "/admin/sign-out" ash_admin "/admin" end @@ -115,16 +121,11 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through :browser - sign_in_route( - overrides: [OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] - ) - reset_route overrides: [ OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default ] - sign_out_route OrcasiteWeb.AuthController auth_routes_for Orcasite.Accounts.User, to: OrcasiteWeb.AuthController end @@ -144,15 +145,7 @@ defmodule OrcasiteWeb.Router do end def log_reverse_proxy_error(error) do - Logger.warn("ReverseProxyPlug network error: #{inspect(error)}") - end - - defp check_authed(conn, _opts) do - conn - |> case do - %{assigns: %{current_user: user}} when not is_nil(user) -> conn - _ -> Phoenix.Controller.redirect(conn, to: "/sign-in") - end + Logger.warning("ReverseProxyPlug network error: #{inspect(error)}") end defp check_admin_path(conn, _opts) do @@ -163,8 +156,11 @@ defmodule OrcasiteWeb.Router do %{assigns: %{current_user: %{admin: true}}, request_path: "/admin" <> _} -> conn + %{request_path: "/admin/sign-in"} -> + conn + %{request_path: "/admin" <> _} -> - Phoenix.Controller.redirect(conn, to: "/sign-in") + Phoenix.Controller.redirect(conn, to: "/admin/sign-in") _ -> conn @@ -178,5 +174,4 @@ defmodule OrcasiteWeb.Router do end defp set_current_user_as_actor(conn, _opts), do: conn - end diff --git a/ui/src/graphql/client.ts b/ui/src/graphql/client.ts index 4bf37f06..c9b6392e 100644 --- a/ui/src/graphql/client.ts +++ b/ui/src/graphql/client.ts @@ -1,3 +1,5 @@ +import { getAuthToken } from "@/utils/auth"; + /* eslint-disable import/no-unused-modules */ if (!process.env.NEXT_PUBLIC_GQL_ENDPOINT) { throw new Error("NEXT_PUBLIC_GQL_ENDPOINT is not set"); @@ -5,8 +7,12 @@ if (!process.env.NEXT_PUBLIC_GQL_ENDPOINT) { export const endpointUrl = process.env.NEXT_PUBLIC_GQL_ENDPOINT; -export const fetchParams = { - headers: { - "Content-Type": "application/json; charset=utf-8", - }, +export const fetchParams = () => { + const authToken = getAuthToken(); + return { + headers: { + "Content-Type": "application/json; charset=utf-8", + ...(authToken && { Authorization: `Bearer ${authToken}` }) + }, + }; }; diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 675f5608..ac14b64b 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -277,6 +277,7 @@ export type Feed = { nodeName: Scalars["String"]["output"]; slug: Scalars["String"]["output"]; thumbUrl?: Maybe; + visible?: Maybe; }; export type FeedFilterId = { @@ -305,6 +306,7 @@ export type FeedFilterInput = { not?: InputMaybe>; or?: InputMaybe>; slug?: InputMaybe; + visible?: InputMaybe; }; export type FeedFilterIntroHtml = { @@ -355,6 +357,17 @@ export type FeedFilterSlug = { notEq?: InputMaybe; }; +export type FeedFilterVisible = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + export type FeedSortField = | "ID" | "IMAGE_URL" @@ -362,7 +375,8 @@ export type FeedSortField = | "LOCATION_POINT" | "NAME" | "NODE_NAME" - | "SLUG"; + | "SLUG" + | "VISIBLE"; export type FeedSortInput = { field: FeedSortField; @@ -513,6 +527,7 @@ export type SubmitDetectionInput = { listenerCount?: InputMaybe; playerOffset: Scalars["Decimal"]["input"]; playlistTimestamp: Scalars["Int"]["input"]; + sendNotifications?: InputMaybe; }; /** The result of the :submit_detection mutation */ @@ -533,6 +548,35 @@ export type User = { lastName?: Maybe; }; +export type SignInWithPasswordMutationVariables = Exact<{ + email: Scalars["String"]["input"]; + password: Scalars["String"]["input"]; +}>; + +export type SignInWithPasswordMutation = { + __typename?: "RootMutationType"; + signInWithPassword?: { + __typename?: "SignInWithPasswordResult"; + token?: string | null; + user?: { + __typename?: "User"; + id: string; + email: string; + admin?: boolean | null; + firstName?: string | null; + lastName?: string | null; + } | null; + errors?: Array<{ + __typename?: "MutationError"; + message?: string | null; + code?: string | null; + fields?: Array | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; + } | null; +}; + export type SubmitDetectionMutationVariables = Exact<{ feedId: Scalars["String"]["input"]; playlistTimestamp: Scalars["Int"]["input"]; @@ -661,6 +705,61 @@ export type FeedsQuery = { }>; }; +export const SignInWithPasswordDocument = ` + mutation signInWithPassword($email: String!, $password: String!) { + signInWithPassword(input: {email: $email, password: $password}) { + token + user { + id + email + admin + firstName + lastName + } + errors { + message + code + fields + shortMessage + vars + } + } +} + `; +export const useSignInWithPasswordMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + SignInWithPasswordMutation, + TError, + SignInWithPasswordMutationVariables, + TContext + >, +) => + useMutation< + SignInWithPasswordMutation, + TError, + SignInWithPasswordMutationVariables, + TContext + >( + ["signInWithPassword"], + (variables?: SignInWithPasswordMutationVariables) => + fetcher( + SignInWithPasswordDocument, + variables, + )(), + options, + ); +useSignInWithPasswordMutation.getKey = () => ["signInWithPassword"]; + +useSignInWithPasswordMutation.fetcher = ( + variables: SignInWithPasswordMutationVariables, +) => + fetcher( + SignInWithPasswordDocument, + variables, + ); export const SubmitDetectionDocument = ` mutation submitDetection($feedId: String!, $playlistTimestamp: Int!, $playerOffset: Decimal!, $description: String!, $listenerCount: Int, $category: DetectionCategory!) { submitDetection( diff --git a/ui/src/styles/theme.ts b/ui/src/styles/theme.ts index 364bb48e..5f1f4b40 100644 --- a/ui/src/styles/theme.ts +++ b/ui/src/styles/theme.ts @@ -58,16 +58,24 @@ const theme = createTheme({ }, }), accent1: { - main: "#008bdf", + main: "#002f49", + dark: "#002a42", + light: "#50c1ff", }, accent2: { - main: "#7c7cfe", + main: "#9b9b9b", + dark: "#8b8b8b", + light: "#d7d7d7", }, accent3: { - main: "#4760fe", + main: "#a4d3d1", + dark: "#8bc7c4", + light: "#dbeded", }, accent4: { - main: "#f79234", + main: "#258dad", + dark: "#217f9c", + light: "#9cd8ea", }, error: { main: "#e9222f", From ced2ce09f7c69f900e411d83a9b842bace1e10db Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 20 Nov 2023 11:51:24 -0800 Subject: [PATCH 02/50] Add sign-in form component, helper functions for storing auth token and current user --- ui/src/components/Auth/SigninForm.tsx | 112 ++++++++++++++++++ .../mutations/signInWithPassword.graphql | 19 +++ ui/src/pages/sign-in/index.tsx | 102 ++++++++++++++++ ui/src/utils/auth.ts | 20 ++++ 4 files changed, 253 insertions(+) create mode 100644 ui/src/components/Auth/SigninForm.tsx create mode 100644 ui/src/graphql/mutations/signInWithPassword.graphql create mode 100644 ui/src/pages/sign-in/index.tsx create mode 100644 ui/src/utils/auth.ts diff --git a/ui/src/components/Auth/SigninForm.tsx b/ui/src/components/Auth/SigninForm.tsx new file mode 100644 index 00000000..0ddf4fd3 --- /dev/null +++ b/ui/src/components/Auth/SigninForm.tsx @@ -0,0 +1,112 @@ +import { Alert, Button, TextField } from "@mui/material"; +import React, { useState } from "react"; + +import { MutationError } from "@/graphql/generated"; + +interface LoginFormProps { + onSubmit: (email: string, password: string) => void; + errors: MutationError[]; +} + +const SigninForm: React.FC = ({ onSubmit, errors }) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const result = onSubmit(email, password); + console.log("Signin result", result); + }; + + return ( +
+ setEmail(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + setPassword(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + {errors && + errors.map((error, index) => ( + + {errorCodeToMessage(error)} + + ))} + + + + ); +}; + +const errorCodeToMessage = (error: Pick) => { + if (error.code === "invalid_credentials") { + return "Your email or password didn't match our records. Please try again."; + } else { + return "An unknown error occurred. Please try again and let us know if this keeps happening."; + } +}; + +export default SigninForm; diff --git a/ui/src/graphql/mutations/signInWithPassword.graphql b/ui/src/graphql/mutations/signInWithPassword.graphql new file mode 100644 index 00000000..7fc1bd11 --- /dev/null +++ b/ui/src/graphql/mutations/signInWithPassword.graphql @@ -0,0 +1,19 @@ +mutation signInWithPassword($email: String!, $password: String!) { + signInWithPassword(input: { email: $email, password: $password }) { + token + user { + id + email + admin + firstName + lastName + } + errors { + message + code + fields + shortMessage + vars + } + } +} diff --git a/ui/src/pages/sign-in/index.tsx b/ui/src/pages/sign-in/index.tsx new file mode 100644 index 00000000..0059d559 --- /dev/null +++ b/ui/src/pages/sign-in/index.tsx @@ -0,0 +1,102 @@ +import { Box, Paper } from "@mui/material"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import SigninForm from "@/components/Auth/SigninForm"; +import Header from "@/components/Header"; +import { + MutationError, + useSignInWithPasswordMutation, +} from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; +import logo from "@/public/wordmark/wordmark-teal.svg"; +import { setAuthToken, setCurrentUser } from "@/utils/auth"; + +const SignInPage: NextPageWithLayout = () => { + const router = useRouter(); + + const [errors, setErrors] = useState([]); + + const submitSignIn = useSignInWithPasswordMutation({ + onMutate: () => { + setErrors([]); + }, + onSuccess: ({ signInWithPassword }) => { + if (signInWithPassword) { + const { token, user, errors } = signInWithPassword; + console.log("token, user, errors", token, user, errors); + + if (errors) { + setErrors( + errors.filter((error) => error !== null) as MutationError[], + ); + } + + if (token) { + setAuthToken(token); + } + + if (user) { + setCurrentUser(user); + router.push("/"); + } + } + }, + onError: (error) => { + console.log("Sign in error", error); + }, + }); + + return ( +
+ + Sign in | Orcasound + + +
+ +
+ + + + Orcasound + + + submitSignIn.mutate({ email, password }) + } + errors={errors} + /> + + + +
+
+ ); +}; + +export default SignInPage; diff --git a/ui/src/utils/auth.ts b/ui/src/utils/auth.ts new file mode 100644 index 00000000..402f8e45 --- /dev/null +++ b/ui/src/utils/auth.ts @@ -0,0 +1,20 @@ +import { User } from "@/graphql/generated"; + +export const setCurrentUser = (user: User) => { + localStorage.setItem("orcasound:user", JSON.stringify(user)); + return user; +}; + +export const getCurrentUser = () => { + const user = localStorage.getItem("orcasound:user"); + if (user) { + return JSON.parse(user) as User; + } +}; + +export const setAuthToken = (token: string) => { + localStorage.setItem("orcasound:auth_token", token); +}; +export const getAuthToken = () => { + return localStorage.getItem("orcasound:auth_token"); +}; From 6ae1f6bfce35e29a695e0bc9369766f3553ed1ba Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 20 Nov 2023 14:05:55 -0800 Subject: [PATCH 03/50] Update fetcher to use dynamic headers --- server/lib/orcasite_web/router.ex | 3 +- ui/codegen.ts | 7 ++--- ui/src/graphql/client.ts | 27 +++++++++++++++- ui/src/graphql/generated/index.ts | 51 +++++++++++++++---------------- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index fc1b8296..db9aad95 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -49,8 +49,7 @@ defmodule OrcasiteWeb.Router do pipeline :graphql do plug(:parsers) - plug :fetch_session - plug :load_from_session + plug :load_from_bearer plug AshGraphql.Plug end diff --git a/ui/codegen.ts b/ui/codegen.ts index 2edb124b..651366c7 100644 --- a/ui/codegen.ts +++ b/ui/codegen.ts @@ -14,7 +14,7 @@ const config: CodegenConfig = { { add: { content: - "import { endpointUrl, fetchParams } from '@/graphql/client';", + "import { fetcher } from '@/graphql/client';", }, }, "typescript", @@ -22,10 +22,7 @@ const config: CodegenConfig = { "typescript-react-query", ], config: { - fetcher: { - endpoint: "endpointUrl", - fetchParams: "fetchParams", - }, + fetcher: "@/graphql/client#fetcher", exposeDocument: true, exposeFetcher: true, exposeQueryKeys: true, diff --git a/ui/src/graphql/client.ts b/ui/src/graphql/client.ts index c9b6392e..d7acaae3 100644 --- a/ui/src/graphql/client.ts +++ b/ui/src/graphql/client.ts @@ -12,7 +12,32 @@ export const fetchParams = () => { return { headers: { "Content-Type": "application/json; charset=utf-8", - ...(authToken && { Authorization: `Bearer ${authToken}` }) + ...(authToken && { Authorization: `Bearer ${authToken}` }), }, }; }; + +export function fetcher( + query: string, + variables?: TVariables, + options?: RequestInit["headers"], +) { + return async (): Promise => { + const res = await fetch(endpointUrl as string, { + method: "POST", + ...fetchParams(), + ...(options || {}), + body: JSON.stringify({ query, variables }), + }); + + const json = await res.json(); + + if (json.errors) { + const { message } = json.errors[0]; + + throw new Error(message); + } + + return json.data; + }; +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index ac14b64b..c7c4e0f7 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -1,4 +1,4 @@ -import { endpointUrl, fetchParams } from "@/graphql/client"; +import { fetcher } from "@/graphql/client"; import { useMutation, useQuery, @@ -25,26 +25,6 @@ export type Incremental = | { [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never; }; - -function fetcher(query: string, variables?: TVariables) { - return async (): Promise => { - const res = await fetch(endpointUrl as string, { - method: "POST", - ...fetchParams, - body: JSON.stringify({ query, variables }), - }); - - const json = await res.json(); - - if (json.errors) { - const { message } = json.errors[0]; - - throw new Error(message); - } - - return json.data; - }; -} /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { ID: { input: string; output: string }; @@ -755,10 +735,12 @@ useSignInWithPasswordMutation.getKey = () => ["signInWithPassword"]; useSignInWithPasswordMutation.fetcher = ( variables: SignInWithPasswordMutationVariables, + options?: RequestInit["headers"], ) => fetcher( SignInWithPasswordDocument, variables, + options, ); export const SubmitDetectionDocument = ` mutation submitDetection($feedId: String!, $playlistTimestamp: Int!, $playerOffset: Decimal!, $description: String!, $listenerCount: Int, $category: DetectionCategory!) { @@ -800,10 +782,12 @@ useSubmitDetectionMutation.getKey = () => ["submitDetection"]; useSubmitDetectionMutation.fetcher = ( variables: SubmitDetectionMutationVariables, + options?: RequestInit["headers"], ) => fetcher( SubmitDetectionDocument, variables, + options, ); export const CandidateDocument = ` query candidate($id: ID!) { @@ -848,10 +832,14 @@ useCandidateQuery.getKey = (variables: CandidateQueryVariables) => [ "candidate", variables, ]; -useCandidateQuery.fetcher = (variables: CandidateQueryVariables) => +useCandidateQuery.fetcher = ( + variables: CandidateQueryVariables, + options?: RequestInit["headers"], +) => fetcher( CandidateDocument, variables, + options, ); export const FeedDocument = ` query feed($slug: String!) { @@ -883,8 +871,10 @@ export const useFeedQuery = ( useFeedQuery.document = FeedDocument; useFeedQuery.getKey = (variables: FeedQueryVariables) => ["feed", variables]; -useFeedQuery.fetcher = (variables: FeedQueryVariables) => - fetcher(FeedDocument, variables); +useFeedQuery.fetcher = ( + variables: FeedQueryVariables, + options?: RequestInit["headers"], +) => fetcher(FeedDocument, variables, options); export const CandidatesDocument = ` query candidates($filter: CandidateFilterInput, $limit: Int, $offset: Int, $sort: [CandidateSortInput]) { candidates(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { @@ -930,10 +920,14 @@ useCandidatesQuery.document = CandidatesDocument; useCandidatesQuery.getKey = (variables?: CandidatesQueryVariables) => variables === undefined ? ["candidates"] : ["candidates", variables]; -useCandidatesQuery.fetcher = (variables?: CandidatesQueryVariables) => +useCandidatesQuery.fetcher = ( + variables?: CandidatesQueryVariables, + options?: RequestInit["headers"], +) => fetcher( CandidatesDocument, variables, + options, ); export const FeedsDocument = ` query feeds { @@ -965,5 +959,8 @@ useFeedsQuery.document = FeedsDocument; useFeedsQuery.getKey = (variables?: FeedsQueryVariables) => variables === undefined ? ["feeds"] : ["feeds", variables]; -useFeedsQuery.fetcher = (variables?: FeedsQueryVariables) => - fetcher(FeedsDocument, variables); +useFeedsQuery.fetcher = ( + variables?: FeedsQueryVariables, + options?: RequestInit["headers"], +) => + fetcher(FeedsDocument, variables, options); From 758434fbf8e69a4887ab64768d311dd9ddc67e69 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 20 Nov 2023 23:13:47 -0800 Subject: [PATCH 04/50] Add register form with /register page --- server/config/dev.exs | 1 + .../accounts/actions/current_user_read.ex | 10 + server/lib/orcasite/accounts/user.ex | 51 +++- .../graphql/errors/authentication.ex | 13 + .../orcasite_web/graphql/types/accounts.ex | 27 -- server/mix.lock | 28 +- ui/src/components/Auth/RegisterForm.tsx | 261 ++++++++++++++++++ ui/src/components/Auth/SigninForm.tsx | 60 +++- ui/src/graphql/generated/index.ts | 188 ++++++++++++- .../mutations/registerWithPassword.graphql | 36 +++ ui/src/pages/register/index.tsx | 124 +++++++++ ui/src/pages/sign-in/index.tsx | 39 ++- 12 files changed, 751 insertions(+), 87 deletions(-) create mode 100644 server/lib/orcasite/accounts/actions/current_user_read.ex create mode 100644 server/lib/orcasite_web/graphql/errors/authentication.ex create mode 100644 ui/src/components/Auth/RegisterForm.tsx create mode 100644 ui/src/graphql/mutations/registerWithPassword.graphql create mode 100644 ui/src/pages/register/index.tsx diff --git a/server/config/dev.exs b/server/config/dev.exs index f9d0c3b9..c2a700dc 100644 --- a/server/config/dev.exs +++ b/server/config/dev.exs @@ -97,3 +97,4 @@ config :hammer, backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} config :orcasite, OrcasiteWeb.BasicAuth, username: "admin", password: "password" +config :ash_graphql, :policies, show_policy_breakdowns?: true diff --git a/server/lib/orcasite/accounts/actions/current_user_read.ex b/server/lib/orcasite/accounts/actions/current_user_read.ex new file mode 100644 index 00000000..a90eeb9d --- /dev/null +++ b/server/lib/orcasite/accounts/actions/current_user_read.ex @@ -0,0 +1,10 @@ +defmodule Orcasite.Accounts.Actions.CurrentUserRead do + use Ash.Resource.ManualRead + + @doc false + @impl true + def read(%{resource: resource}, _, _, %{actor: actor}) when is_struct(actor, resource), + do: {:ok, [actor]} + + def read(_, _, _, _), do: {:ok, []} +end diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index c6fd1a89..313dab1c 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -3,6 +3,15 @@ defmodule Orcasite.Accounts.User do data_layer: AshPostgres.DataLayer, extensions: [AshAuthentication, AshAdmin.Resource, AshGraphql.Resource] + postgres do + table "users" + repo Orcasite.Repo + end + + identities do + identity :unique_email, [:email] + end + attributes do uuid_primary_key :id attribute :email, :ci_string, allow_nil?: false @@ -23,6 +32,8 @@ defmodule Orcasite.Accounts.User do identity_field :email sign_in_tokens_enabled? true + register_action_accept [:first_name, :last_name] + resettable do sender fn user, token, opts -> Task.Supervisor.async_nolink(Orcasite.TaskSupervisor, fn -> @@ -39,15 +50,21 @@ defmodule Orcasite.Accounts.User do token_resource Orcasite.Accounts.Token signing_secret Orcasite.Accounts.Secrets end - end - postgres do - table "users" - repo Orcasite.Repo + select_for_senders [:id, :email, :first_name, :last_name] end - identities do - identity :unique_email, [:email] + actions do + defaults [:read, :create, :update, :destroy] + + read :by_email do + get_by :email + end + + read :current_user do + get? true + manual Orcasite.Accounts.Actions.CurrentUserRead + end end code_interface do @@ -58,14 +75,6 @@ defmodule Orcasite.Accounts.User do define :by_email, args: [:email] end - actions do - defaults [:read, :create, :update, :destroy] - - read :by_email do - get_by :email - end - end - admin do table_columns [:id, :email, :first_name, :last_name, :admin, :inserted_at] end @@ -73,5 +82,19 @@ defmodule Orcasite.Accounts.User do graphql do type :user hide_fields [:hashed_password] + + queries do + read_one :current_user, :current_user + end + + mutations do + create :register_with_password, :register_with_password + end end + + # policies do + # bypass AshAuthentication.Checks.AshAuthenticationInteraction do + # authorize_if always() + # end + # end end diff --git a/server/lib/orcasite_web/graphql/errors/authentication.ex b/server/lib/orcasite_web/graphql/errors/authentication.ex new file mode 100644 index 00000000..a3c2095b --- /dev/null +++ b/server/lib/orcasite_web/graphql/errors/authentication.ex @@ -0,0 +1,13 @@ +defimpl AshGraphql.Error, for: AshAuthentication.Errors.AuthenticationFailed do + def to_error(error) do + IO.inspect(error, label: "auth error") + + %{ + message: error.caused_by.message, + short_message: "invalid_credentials", + vars: error.vars, + code: "invalid", + fields: [] + } + end +end diff --git a/server/lib/orcasite_web/graphql/types/accounts.ex b/server/lib/orcasite_web/graphql/types/accounts.ex index 5aea5523..52a02177 100644 --- a/server/lib/orcasite_web/graphql/types/accounts.ex +++ b/server/lib/orcasite_web/graphql/types/accounts.ex @@ -14,18 +14,6 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do field :errors, list_of(:mutation_error) end - input_object :register_with_password_input do - field :email, non_null(:string) - field :password, non_null(:string) - field :password_confirmation, non_null(:string) - end - - object :register_with_password_result do - field :token, :string - field :user, :user - field :errors, list_of(:mutation_error) - end - object :accounts_user_mutations do field :sign_in_with_password, type: :sign_in_with_password_result do arg(:input, non_null(:sign_in_with_password_input)) @@ -39,20 +27,5 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do end end) end - - field :register_with_password, type: :register_with_password_result do - arg(:input, non_null(:register_with_password_input)) - - resolve(fn _, %{input: args}, _ -> - with {:ok, user} <- User.register_with_password(args) do - {:ok, %{user: user, token: user.__metadata__.token}} - else - {:error, %{errors: errors}} -> - errors = Enum.map(errors, &AshGraphql.Error.to_error/1) - - {:ok, %{errors: errors}} - end - end) - end end end diff --git a/server/mix.lock b/server/mix.lock index fb5fca6b..244c3591 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -1,16 +1,16 @@ %{ - "absinthe": {:hex, :absinthe, "1.7.5", "a15054f05738e766f7cc7fd352887dfd5e61cec371fb4741cca37c3359ff74ac", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "22a9a38adca26294ad0ee91226168f5d215b401efd770b8a1b8fd9c9b21ec316"}, + "absinthe": {:hex, :absinthe, "1.7.6", "0b897365f98d068cfcb4533c0200a8e58825a4aeeae6ec33633ebed6de11773b", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7626951ca5eec627da960615b51009f3a774765406ff02722b1d818f17e5778"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, - "ash": {:hex, :ash, "2.16.1", "0c7f61275d9a30ab0c3d0fa68e51102227287d4c021351d3bd0fb1c6c456db98", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.50 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.6", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6d3d2a419d6ca3041194caf6dab93dda2e759273ea0161942beace2b3441b7b"}, + "ash": {:hex, :ash, "2.17.3", "b87448baa52360d3a8934526bb9e6c29cbbc0fa0047884a8d5616fda7ee263a4", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.50 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.6", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "03d310f44240d8c47e7b405afcc8d87b290dac702698d1b470374ba54ae45c49"}, "ash_admin": {:git, "https://github.com/skanderm/ash_admin.git", "b0e018c7aaeecc3d74d48f354c76c9b3f434b8e7", [branch: "main"]}, "ash_authentication": {:hex, :ash_authentication, "3.11.16", "018917a985d44509d5a571d000d40aa499806baba473f6292c73bce7b3c7dc29", [:mix], [{:ash, ">= 2.5.11 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:assent, "~> 0.2", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.16.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 1.1.39 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c15d53519df6fa2c9896d148f4ffd77d836445d256bb92292f865bd6932c6432"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "1.8.7", "b6349049c08819b9d0eea5996b29863b37d5c37dcd2179e4c123e6f13dbd4c59", [:mix], [{:ash, "~> 2.2", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 3.11.9 and < 4.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 1.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "dd767e84d4514cc313712667529964ae89585a556292700558601086f7da45c3"}, "ash_graphql": {:hex, :ash_graphql, "0.26.6", "38a2a85cc58d31cce576e601e6d6932ae329a80f11b4184d083d391b7523ab03", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 2.14.17 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e16e5c28cc050a8ef9c9fc5e50c3d14b7b670d9c42ed6e4449c6844ee9325ed0"}, "ash_json_api": {:hex, :ash_json_api, "0.33.1", "697444ff088235eb742c8661cc127c18cdebadb55cb944fc38bdef7b87df0a7e", [:mix], [{:ash, ">= 2.9.24 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2767041ae3a763bd05c89ca430ec3d649a7aae161c503314abcf72f381bb2679"}, "ash_phoenix": {:hex, :ash_phoenix, "1.2.23", "9d98bb2c1f9762e27411a5a021b43d5fb5ab716f195346d2b5edc422d789ed23", [:mix], [{:ash, ">= 2.14.1 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "64a09ad8969e83a36da0975f66e0d3acfb66754e0d091da7bfe1c94b70a5b6ff"}, - "ash_postgres": {:hex, :ash_postgres, "1.3.60", "0fb398dd6bdf1989cf4a95e6d5f02e6a424e0a9a36619592ff33b51769da1406", [:mix], [{:ash, ">= 2.15.18 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "625546cfb658335eafe1de1a9db85ee1d195384ec93346fcfa14b103628e7da3"}, + "ash_postgres": {:hex, :ash_postgres, "1.3.62", "e8b661a0a88a771f7139dcd7c9632cc140f9b05c278cc0ee297638cb47782c1f", [:mix], [{:ash, ">= 2.17.3 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "99e5702f72ec54d65a2571bebb89e3867ca7220eeca7c89aa7935dcf6e0cc0a7"}, "ash_uuid": {:hex, :ash_uuid, "0.6.0", "e78a2bd5ad276f9e01865891dea535105b12de3a925c6a14684ba31fd4559361", [:mix], [{:ash, "~> 2.13", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 1.3.41", [hex: :ash_postgres, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "b19499ca47361db2c9bcd98dea338817f4ddc13de4a8b167249b4db074377d0e"}, - "assent": {:hex, :assent, "0.2.7", "aa68f68e577077c091ce722bff8fe1ae56b95b274bb8107f7a5406cc15a65da7", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "08106af439de4f9de114c0334de4c848de7cfbe53a5a52d342a784c4f6bc86f3"}, + "assent": {:hex, :assent, "0.2.8", "72abd81d182e2a2902c74d926eb1b0c186756299f4393a6844ea4757407731e6", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "9f977d0358402a6c8807f10faa9876f997186447e1b353d191248007eb45acfe"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, @@ -25,10 +25,10 @@ "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"}, - "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, - "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, + "ecto": {:hex, :ecto, "3.11.0", "ff8614b4e70a774f9d39af809c426def80852048440e8785d93a6e91f48fec00", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7769dad267ef967310d6e988e92d772659b11b09a0c015f101ce0fff81ce1f81"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.0", "c787b24b224942b69c9ff7ab9107f258ecdc68326be04815c6cce2941b6fad1c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "77aa3677169f55c2714dda7352d563002d180eb33c0dc29cd36d39c0a1a971f5"}, "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, - "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, + "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, @@ -60,15 +60,15 @@ "nebulex": {:hex, :nebulex, "2.5.2", "2d358813ccb2eeea525e3a29c270ad123d3337e97ed9159d9113cf128108bd4c", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "61a122302cf42fa61eca22515b1df21aaaa1b98cf462f6dd0998de9797aaf1c7"}, "nebulex_redis_adapter": {:hex, :nebulex_redis_adapter, "2.3.1", "ea57629ee21f78332ca8d0356e6777d2fdbd6755b7453e298557091b6f8811f6", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: true]}, {:jchash, "~> 0.1", [hex: :jchash, repo: "hexpm", optional: true]}, {:nebulex, "~> 2.5", [hex: :nebulex, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:redix, "~> 1.2", [hex: :redix, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "8384bf249af7c0b9903578b5c75a18157562863054952dbaea55dfe7255b75e7"}, "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "oban": {:hex, :oban, "2.16.3", "33ebe7da637cce4da5438c1636bc25448a8628994a0c064ac6078bbe6dc97bd6", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d8a7fb62f63cf2f2080c78954425f5fd8916ef57196b7f79b5bc657abb2ac5f"}, "open_api_spex": {:hex, :open_api_spex, "3.18.0", "f9952b6bc8a1bf14168f3754981b7c8d72d015112bfedf2588471dd602e1e715", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "37849887ab67efab052376401fac28c0974b273ffaecd98f4532455ca0886464"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.2", "b9e33c950d1ed98494bfbde1c34c6e51c8a4214f3bea3f07ca9a510643ee1387", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "67a598441b5f583d301a77e0298719f9654887d3d8bf14e80ff0b6acf887ef90"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.1", "92a37acf07afca67ac98bd326532ba8f44ad7d4bdf3e4361b03f7f02594e5ae9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be494fd1215052729298b0e97d5c2ce8e719c00854b82cd8cf15c1cd7fcf6294"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, @@ -76,7 +76,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, - "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, @@ -91,13 +91,13 @@ "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sourceror": {:hex, :sourceror, "0.14.1", "c6fb848d55bd34362880da671debc56e77fd722fa13b4dcbeac89a8998fc8b09", [:mix], [], "hexpm", "8b488a219e4c4d7d9ff29d16346fd4a5858085ccdd010e509101e226bbfd8efc"}, - "spark": {:hex, :spark, "1.1.50", "809da1214151ad7c592389b3ea85eb4424f727b681b90439d8ffbe5305400ce9", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "ed9b1b817b52c3aaeee283032640857ee9d8398b8c4e9e7d78d77929d387b9a1"}, + "spark": {:hex, :spark, "1.1.51", "8458de5abbb89d18dd5c9235dd39e3757076eba84a5078d1cdc2c1e23c39aa95", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "ed8410aa8db08867b8fff3d65e54deeb7f6f6cf2b8698fc405a386c1c7a9e4f0"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"}, - "swoosh": {:hex, :swoosh, "1.14.0", "710e363e114dedb4080b737e0307f5410887ffc9a239f818231e5618b6b84e1b", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dccfc986ac99c18345ab3e1a8b934b2d817fd6d59a2494f0af78502184c71025"}, + "swoosh": {:hex, :swoosh, "1.14.1", "d8813699ba410509008dd3dfdb2df057e3fce367d45d5e6d76b146a7c9d559cd", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87da72260b4351678f96aec61db5c2acc8a88cda2cf2c4f534eb4c9c461350c7"}, "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, "tails": {:hex, :tails, "0.1.7", "d77a89c2faea02237d78ea81824c1362dbc3cfa4e2a203be7808617ae47bb5eb", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d5ae73c55d181ab1353f2c49aea2ef540edfa6ec6be65a89e8a392ba15a94b21"}, - "tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"}, + "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, diff --git a/ui/src/components/Auth/RegisterForm.tsx b/ui/src/components/Auth/RegisterForm.tsx new file mode 100644 index 00000000..36f4e0fd --- /dev/null +++ b/ui/src/components/Auth/RegisterForm.tsx @@ -0,0 +1,261 @@ +import { Alert, Box, Button, Link, TextField } from "@mui/material"; +import NextLink from "next/link"; +import React, { useState } from "react"; + +import { MutationError } from "@/graphql/generated"; + +interface RegisterFormProps { + onSubmit: ( + firstName: string, + lastName: string, + email: string, + password: string, + passwordConfirmation: string, + ) => void; + errors: MutationError[]; +} + +const RegisterForm: React.FC = ({ onSubmit, errors }) => { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(firstName, lastName, email, password, passwordConfirmation); + }; + + return ( +
+ error?.fields?.includes("first_name"))} + helperText={ + errors.find((error) => error?.fields?.includes("first_name"))?.message + } + onChange={(event) => setFirstName(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + error?.fields?.includes("last_name"))} + helperText={ + errors.find((error) => error?.fields?.includes("last_name"))?.message + } + onChange={(event) => setLastName(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + setEmail(event.target.value)} + error={!!errors.find((error) => error?.fields?.includes("email"))} + helperText={ + errors.find((error) => error?.fields?.includes("email"))?.message + } + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + error?.fields?.includes("password"))} + helperText={ + errors.find((error) => error?.fields?.includes("password"))?.message + } + onChange={(event) => setPassword(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + error?.fields?.includes("password_confirmation"), + ) + } + helperText={ + errors.find( + (error) => error?.fields?.includes("password_confirmation"), + )?.message + } + onChange={(event) => setPasswordConfirmation(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + {errors && + errors + .filter((error) => error?.fields?.length === 0) + .map((error, index) => ( + + {errorCodeToMessage(error)} + + ))} + + + theme.palette.accent4.main, + }} + href="/" + > + Forgot your password? + + theme.palette.accent4.main, + }} + href="/sign-in" + textAlign="right" + > + Already have an account? + + + + + + ); +}; + +const errorCodeToMessage = (_error: Pick) => { + return "An unknown error occurred. Please try again and let us know if this keeps happening."; +}; + +export default RegisterForm; diff --git a/ui/src/components/Auth/SigninForm.tsx b/ui/src/components/Auth/SigninForm.tsx index 0ddf4fd3..9cb74e8e 100644 --- a/ui/src/components/Auth/SigninForm.tsx +++ b/ui/src/components/Auth/SigninForm.tsx @@ -1,21 +1,19 @@ -import { Alert, Button, TextField } from "@mui/material"; +import { Alert, Box, Button, Link, TextField } from "@mui/material"; +import NextLink from "next/link"; import React, { useState } from "react"; -import { MutationError } from "@/graphql/generated"; - -interface LoginFormProps { +interface SignInFormProps { onSubmit: (email: string, password: string) => void; - errors: MutationError[]; + errors?: string[]; } -const SigninForm: React.FC = ({ onSubmit, errors }) => { +const SignInForm: React.FC = ({ onSubmit, errors }) => { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - const result = onSubmit(email, password); - console.log("Signin result", result); + onSubmit(email, password); }; return ( @@ -77,12 +75,42 @@ const SigninForm: React.FC = ({ onSubmit, errors }) => { }} /> {errors && - errors.map((error, index) => ( - + errors.map((error) => ( + {errorCodeToMessage(error)} ))} + + theme.palette.accent4.main, + }} + href="/" + > + Forgot your password? + + theme.palette.accent4.main, + }} + href="/register" + textAlign="right" + > + Need an account? + + + + + ); +}; + +const errorCodeToMessage = (error: string) => { + if (error === "invalid_credentials") { + return "Your email and password didn't match our records. Please try again."; + } else if (error) { + return `An error occurred: ${error}. Please try again and let us know if this keeps happening.`; + } else { + return `An unknown error occurred. Please try again and let us know if this keeps happening.`; + } +}; + +export default ForgotPasswordForm; diff --git a/ui/src/components/Auth/SigninForm.tsx b/ui/src/components/Auth/SigninForm.tsx index 9cb74e8e..f473702c 100644 --- a/ui/src/components/Auth/SigninForm.tsx +++ b/ui/src/components/Auth/SigninForm.tsx @@ -92,8 +92,9 @@ const SignInForm: React.FC = ({ onSubmit, errors }) => { sx={{ textDecoration: "none", color: (theme) => theme.palette.accent4.main, + marginRight: 2, }} - href="/" + href="/password-reset" > Forgot your password? diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index eec029a9..625732ad 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -1,10 +1,10 @@ -import { fetcher } from "@/graphql/client"; import { useMutation, useQuery, UseMutationOptions, UseQueryOptions, } from "@tanstack/react-query"; +import { fetcher } from "@/graphql/client"; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { @@ -433,10 +433,15 @@ export type RegisterWithPasswordResult = { result?: Maybe; }; +export type RequestPasswordResetInput = { + email: Scalars["String"]["input"]; +}; + export type RootMutationType = { __typename?: "RootMutationType"; /** Register a new user with a username and password. */ registerWithPassword?: Maybe; + requestPasswordReset?: Maybe; signInWithPassword?: Maybe; submitDetection?: Maybe; }; @@ -445,6 +450,10 @@ export type RootMutationTypeRegisterWithPasswordArgs = { input?: InputMaybe; }; +export type RootMutationTypeRequestPasswordResetArgs = { + input: RequestPasswordResetInput; +}; + export type RootMutationTypeSignInWithPasswordArgs = { input: SignInWithPasswordInput; }; @@ -649,6 +658,15 @@ export type RegisterWithPasswordMutation = { } | null; }; +export type RequestPasswordResetMutationVariables = Exact<{ + email: Scalars["String"]["input"]; +}>; + +export type RequestPasswordResetMutation = { + __typename?: "RootMutationType"; + requestPasswordReset?: boolean | null; +}; + export type SignInWithPasswordMutationVariables = Exact<{ email: Scalars["String"]["input"]; password: Scalars["String"]["input"]; @@ -867,6 +885,47 @@ useRegisterWithPasswordMutation.fetcher = ( variables, options, ); +export const RequestPasswordResetDocument = ` + mutation requestPasswordReset($email: String!) { + requestPasswordReset(input: {email: $email}) +} + `; +export const useRequestPasswordResetMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + RequestPasswordResetMutation, + TError, + RequestPasswordResetMutationVariables, + TContext + >, +) => + useMutation< + RequestPasswordResetMutation, + TError, + RequestPasswordResetMutationVariables, + TContext + >( + ["requestPasswordReset"], + (variables?: RequestPasswordResetMutationVariables) => + fetcher< + RequestPasswordResetMutation, + RequestPasswordResetMutationVariables + >(RequestPasswordResetDocument, variables)(), + options, + ); +useRequestPasswordResetMutation.getKey = () => ["requestPasswordReset"]; + +useRequestPasswordResetMutation.fetcher = ( + variables: RequestPasswordResetMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + RequestPasswordResetDocument, + variables, + options, + ); export const SignInWithPasswordDocument = ` mutation signInWithPassword($email: String!, $password: String!) { signInWithPassword(input: {email: $email, password: $password}) { diff --git a/ui/src/graphql/mutations/requestPasswordReset.graphql b/ui/src/graphql/mutations/requestPasswordReset.graphql new file mode 100644 index 00000000..1603b63f --- /dev/null +++ b/ui/src/graphql/mutations/requestPasswordReset.graphql @@ -0,0 +1,3 @@ +mutation requestPasswordReset($email: String!) { + requestPasswordReset(input: { email: $email }) +} diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx new file mode 100644 index 00000000..e69de29b diff --git a/ui/src/pages/password-reset/index.tsx b/ui/src/pages/password-reset/index.tsx new file mode 100644 index 00000000..938db505 --- /dev/null +++ b/ui/src/pages/password-reset/index.tsx @@ -0,0 +1,100 @@ +import { Box, Paper } from "@mui/material"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import ResetPasswordRequestForm from "@/components/Auth/ResetPasswordRequestForm"; +import Header from "@/components/Header"; +import { useRequestPasswordResetMutation } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; +import logo from "@/public/wordmark/wordmark-teal.svg"; + +const RegisterPage: NextPageWithLayout = () => { + const router = useRouter(); + + const [message, setMessage] = useState(); + + const submitPasswordResetRequest = useRequestPasswordResetMutation({ + onMutate: () => { + setMessage(undefined); + }, + onSuccess: ({ requestPasswordReset }) => { + if (requestPasswordReset) { + setMessage("Password reset has been sent to the email provided."); + } + }, + onError: (error) => { + console.log("Forgot password error", error); + }, + }); + + return ( +
+ + Register | Orcasound + + +
+ +
+ + + + Orcasound + + + submitPasswordResetRequest.mutate({ + email, + }) + } + message={message} + /> + + + +
+
+ ); +}; + +export default RegisterPage; From c281c0710dd00cc6fa98ab9c53f85ba7b05369b3 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 21 Nov 2023 13:01:19 -0800 Subject: [PATCH 06/50] Update password reset form button text --- ui/src/components/Auth/ResetPasswordRequestForm.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/ui/src/components/Auth/ResetPasswordRequestForm.tsx b/ui/src/components/Auth/ResetPasswordRequestForm.tsx index b5cccc05..3373ce34 100644 --- a/ui/src/components/Auth/ResetPasswordRequestForm.tsx +++ b/ui/src/components/Auth/ResetPasswordRequestForm.tsx @@ -91,20 +91,10 @@ const ForgotPasswordForm: React.FC = ({ "&:hover": { backgroundColor: (theme) => theme.palette.accent4.dark }, }} > - Sign In + Request password reset ); }; -const errorCodeToMessage = (error: string) => { - if (error === "invalid_credentials") { - return "Your email and password didn't match our records. Please try again."; - } else if (error) { - return `An error occurred: ${error}. Please try again and let us know if this keeps happening.`; - } else { - return `An unknown error occurred. Please try again and let us know if this keeps happening.`; - } -}; - export default ForgotPasswordForm; From e4f226cb7a21455c890d05e01e071b4a601f6494 Mon Sep 17 00:00:00 2001 From: skanderm Date: Tue, 21 Nov 2023 16:09:34 -0500 Subject: [PATCH 07/50] Rename SigninForm.tsx to SignInForm.tsx --- ui/src/components/Auth/{SigninForm.tsx => SignInForm.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ui/src/components/Auth/{SigninForm.tsx => SignInForm.tsx} (100%) diff --git a/ui/src/components/Auth/SigninForm.tsx b/ui/src/components/Auth/SignInForm.tsx similarity index 100% rename from ui/src/components/Auth/SigninForm.tsx rename to ui/src/components/Auth/SignInForm.tsx From f434de74de69f290bf71884611501b5688675874 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 21 Nov 2023 18:02:37 -0800 Subject: [PATCH 08/50] Add reset password form, mutation --- server/lib/orcasite/accounts/user.ex | 1 + .../orcasite_web/graphql/types/accounts.ex | 44 +++++ ui/src/components/Auth/ResetPasswordForm.tsx | 161 ++++++++++++++++++ .../Auth/ResetPasswordRequestForm.tsx | 1 + ui/src/graphql/generated/index.ts | 104 +++++++++++ .../graphql/mutations/resetPassword.graphql | 29 ++++ ui/src/pages/password-reset/[token].tsx | 119 +++++++++++++ ui/src/pages/password-reset/index.tsx | 6 +- 8 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/Auth/ResetPasswordForm.tsx create mode 100644 ui/src/graphql/mutations/resetPassword.graphql diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index 068000ac..741c15cb 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -75,6 +75,7 @@ defmodule Orcasite.Accounts.User do define :sign_in_with_password define :by_email, args: [:email] define :request_password_reset_with_password + define :password_reset_with_password end admin do diff --git a/server/lib/orcasite_web/graphql/types/accounts.ex b/server/lib/orcasite_web/graphql/types/accounts.ex index 1f0d69bf..f4deefbd 100644 --- a/server/lib/orcasite_web/graphql/types/accounts.ex +++ b/server/lib/orcasite_web/graphql/types/accounts.ex @@ -18,6 +18,18 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do field :email, non_null(:string) end + input_object :password_reset_input do + field :reset_token, non_null(:string) + field :password, non_null(:string) + field :password_confirmation, non_null(:string) + end + + object :password_reset_result do + field :token, :string + field :user, :user + field :errors, list_of(:mutation_error) + end + object :accounts_user_mutations do field :sign_in_with_password, type: :sign_in_with_password_result do arg(:input, non_null(:sign_in_with_password_input)) @@ -41,5 +53,37 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do {:ok, true} end) end + + field :reset_password, type: :password_reset_result do + arg(:input, non_null(:password_reset_input)) + + resolve(fn _, + %{ + input: %{ + password: password, + password_confirmation: password_confirmation, + reset_token: reset_token + } + }, + _ -> + strategy = AshAuthentication.Info.strategy!(Orcasite.Accounts.User, :password) + + with {:ok, user} <- + AshAuthentication.Strategy.Password.Actions.reset( + strategy, + %{ + "password" => password, + "password_confirmation" => password_confirmation, + "reset_token" => reset_token + }, + [] + ) do + {:ok, %{user: user, token: user.__metadata__.token}} + else + {:error, err} -> + {:ok, %{errors: Enum.map(err.errors, &AshGraphql.Error.to_error/1)}} + end + end) + end end end diff --git a/ui/src/components/Auth/ResetPasswordForm.tsx b/ui/src/components/Auth/ResetPasswordForm.tsx new file mode 100644 index 00000000..d1f27a3f --- /dev/null +++ b/ui/src/components/Auth/ResetPasswordForm.tsx @@ -0,0 +1,161 @@ +import { Alert, Button, TextField } from "@mui/material"; +import React, { useState } from "react"; + +import { MutationError } from "@/graphql/generated"; + +interface ResetPasswordFormProps { + onSubmit: (password: string, passworConfirmation: string) => void; + errors: MutationError[]; +} + +const ResetPasswordForm: React.FC = ({ + onSubmit, + errors, +}) => { + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(password, passwordConfirmation); + }; + + return ( +
+ error?.fields?.includes("password"))} + helperText={errorToString( + errors.find((error) => error?.fields?.includes("password")), + true, + )} + onChange={(event) => setPassword(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + error?.fields?.includes("password_confirmation"), + ) + } + helperText={errorToString( + errors.find( + (error) => error?.fields?.includes("password_confirmation"), + ), + true, + )} + onChange={(event) => setPasswordConfirmation(event.target.value)} + sx={{ + "& .MuiFormLabel-root": { + color: (theme) => theme.palette.secondary.light, + }, + "& .MuiFormLabel-root.Mui-focused": { + color: (theme) => theme.palette.secondary.dark, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: (theme) => theme.palette.accent2.main, + }, + "&:hover fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + "&.Mui-focused fieldset": { + borderColor: (theme) => theme.palette.accent2.dark, + }, + }, + }} + /> + {errors && + errors + .filter((error) => error?.fields?.length === 0) + .map((error, index) => ( + + {errorToString(error)} + + ))} + + + + ); +}; + +const errorToString = ( + error?: Pick, + shortMessage: boolean = false, +) => { + if (!error) { + return ""; + } + + if ( + typeof error.vars === "object" && + error.vars && + Object.keys(error.vars).length > 0 + ) { + const vars = error.vars; + return Object.keys(error.vars) + .filter((key) => key !== "message" && key !== "field") + .reduce( + (acc, key) => acc.replaceAll(`%{${key}}`, vars[key]), + error.vars.message, + ); + } + + if (shortMessage) { + return error.shortMessage; + } + + if (error.message) { + return `An error occurred: ${error.message}. Please try again and let us know if this keeps happening.`; + } else { + return `An unknown error occurred. Please try again and let us know if this keeps happening.`; + } +}; + +export default ResetPasswordForm; diff --git a/ui/src/components/Auth/ResetPasswordRequestForm.tsx b/ui/src/components/Auth/ResetPasswordRequestForm.tsx index 3373ce34..bb877ca6 100644 --- a/ui/src/components/Auth/ResetPasswordRequestForm.tsx +++ b/ui/src/components/Auth/ResetPasswordRequestForm.tsx @@ -16,6 +16,7 @@ const ForgotPasswordForm: React.FC = ({ const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); onSubmit(email); + setEmail(""); }; return ( diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 625732ad..044e210f 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -406,6 +406,19 @@ export type PageOfDetection = { results?: Maybe>; }; +export type PasswordResetInput = { + password: Scalars["String"]["input"]; + passwordConfirmation: Scalars["String"]["input"]; + resetToken: Scalars["String"]["input"]; +}; + +export type PasswordResetResult = { + __typename?: "PasswordResetResult"; + errors?: Maybe>>; + token?: Maybe; + user?: Maybe; +}; + export type RegisterWithPasswordInput = { email: Scalars["String"]["input"]; firstName?: InputMaybe; @@ -442,6 +455,7 @@ export type RootMutationType = { /** Register a new user with a username and password. */ registerWithPassword?: Maybe; requestPasswordReset?: Maybe; + resetPassword?: Maybe; signInWithPassword?: Maybe; submitDetection?: Maybe; }; @@ -454,6 +468,10 @@ export type RootMutationTypeRequestPasswordResetArgs = { input: RequestPasswordResetInput; }; +export type RootMutationTypeResetPasswordArgs = { + input: PasswordResetInput; +}; + export type RootMutationTypeSignInWithPasswordArgs = { input: SignInWithPasswordInput; }; @@ -667,6 +685,36 @@ export type RequestPasswordResetMutation = { requestPasswordReset?: boolean | null; }; +export type ResetPasswordMutationVariables = Exact<{ + password: Scalars["String"]["input"]; + passwordConfirmation: Scalars["String"]["input"]; + resetToken: Scalars["String"]["input"]; +}>; + +export type ResetPasswordMutation = { + __typename?: "RootMutationType"; + resetPassword?: { + __typename?: "PasswordResetResult"; + token?: string | null; + errors?: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; + user?: { + __typename?: "User"; + id: string; + email: string; + firstName?: string | null; + lastName?: string | null; + admin?: boolean | null; + } | null; + } | null; +}; + export type SignInWithPasswordMutationVariables = Exact<{ email: Scalars["String"]["input"]; password: Scalars["String"]["input"]; @@ -926,6 +974,62 @@ useRequestPasswordResetMutation.fetcher = ( variables, options, ); +export const ResetPasswordDocument = ` + mutation resetPassword($password: String!, $passwordConfirmation: String!, $resetToken: String!) { + resetPassword( + input: {password: $password, passwordConfirmation: $passwordConfirmation, resetToken: $resetToken} + ) { + token + errors { + code + fields + message + shortMessage + vars + } + user { + id + email + firstName + lastName + admin + } + } +} + `; +export const useResetPasswordMutation = ( + options?: UseMutationOptions< + ResetPasswordMutation, + TError, + ResetPasswordMutationVariables, + TContext + >, +) => + useMutation< + ResetPasswordMutation, + TError, + ResetPasswordMutationVariables, + TContext + >( + ["resetPassword"], + (variables?: ResetPasswordMutationVariables) => + fetcher( + ResetPasswordDocument, + variables, + )(), + options, + ); +useResetPasswordMutation.getKey = () => ["resetPassword"]; + +useResetPasswordMutation.fetcher = ( + variables: ResetPasswordMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + ResetPasswordDocument, + variables, + options, + ); export const SignInWithPasswordDocument = ` mutation signInWithPassword($email: String!, $password: String!) { signInWithPassword(input: {email: $email, password: $password}) { diff --git a/ui/src/graphql/mutations/resetPassword.graphql b/ui/src/graphql/mutations/resetPassword.graphql new file mode 100644 index 00000000..203a0c7a --- /dev/null +++ b/ui/src/graphql/mutations/resetPassword.graphql @@ -0,0 +1,29 @@ +mutation resetPassword( + $password: String! + $passwordConfirmation: String! + $resetToken: String! +) { + resetPassword( + input: { + password: $password + passwordConfirmation: $passwordConfirmation + resetToken: $resetToken + } + ) { + token + errors { + code + fields + message + shortMessage + vars + } + user { + id + email + firstName + lastName + admin + } + } +} diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx index e69de29b..bffb44e4 100644 --- a/ui/src/pages/password-reset/[token].tsx +++ b/ui/src/pages/password-reset/[token].tsx @@ -0,0 +1,119 @@ +import { Box, Paper } from "@mui/material"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { useState } from "react"; + +import ResetPasswordForm from "@/components/Auth/ResetPasswordForm"; +import Header from "@/components/Header"; +import { MutationError, useResetPasswordMutation } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; +import logo from "@/public/wordmark/wordmark-teal.svg"; +import { setAuthToken, setCurrentUser } from "@/utils/auth"; + +const PasswordResetPage: NextPageWithLayout = () => { + const router = useRouter(); + const token = router.query.token as string; + + const [errors, setErrors] = useState([]); + + const submitResetPassword = useResetPasswordMutation({ + onMutate: () => { + setErrors([]); + }, + onSuccess: ({ resetPassword }) => { + if (resetPassword) { + const { token, errors, user } = resetPassword; + + if (errors && errors?.length > 0) { + setErrors( + errors.filter((error): error is MutationError => error !== null), + ); + } + + if (token) { + setAuthToken(token); + } + + if (user) { + setCurrentUser(user); + router.push("/"); + } + } + }, + onError: (error) => { + console.log("Forgot password error", error); + }, + }); + + return ( +
+ + Reset password | Orcasound + + +
+ +
+ + + + Orcasound + + + submitResetPassword.mutate({ + password, + passwordConfirmation, + resetToken: token, + }) + } + errors={errors} + /> + + + +
+
+ ); +}; + +export default PasswordResetPage; diff --git a/ui/src/pages/password-reset/index.tsx b/ui/src/pages/password-reset/index.tsx index 938db505..1391c131 100644 --- a/ui/src/pages/password-reset/index.tsx +++ b/ui/src/pages/password-reset/index.tsx @@ -10,7 +10,7 @@ import { useRequestPasswordResetMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import logo from "@/public/wordmark/wordmark-teal.svg"; -const RegisterPage: NextPageWithLayout = () => { +const PasswordResetRequestPage: NextPageWithLayout = () => { const router = useRouter(); const [message, setMessage] = useState(); @@ -32,7 +32,7 @@ const RegisterPage: NextPageWithLayout = () => { return (
- Register | Orcasound + Request password reset | Orcasound
@@ -97,4 +97,4 @@ const RegisterPage: NextPageWithLayout = () => { ); }; -export default RegisterPage; +export default PasswordResetRequestPage; From f4274a788ac7af31426f681f03b5e43f3bcadd54 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 22 Nov 2023 11:12:46 -0800 Subject: [PATCH 09/50] Move rest of backend auth routes to /admin prefix --- server/lib/orcasite_web/router.ex | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index db9aad95..063b26dd 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -101,6 +101,14 @@ defmodule OrcasiteWeb.Router do overrides: [OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] ) + reset_route path: "/admin/password-reset", + overrides: [ + OrcasiteWeb.AuthOverrides, + AshAuthentication.Phoenix.Overrides.Default + ] + + auth_routes_for Orcasite.Accounts.User, to: OrcasiteWeb.AuthController, path: "/admin" + sign_out_route OrcasiteWeb.AuthController, "/admin/sign-out" ash_admin "/admin" end @@ -117,17 +125,6 @@ defmodule OrcasiteWeb.Router do sign_out_route OrcasiteWeb.SubscriptionAuthController end - scope "/" do - pipe_through :browser - - reset_route overrides: [ - OrcasiteWeb.AuthOverrides, - AshAuthentication.Phoenix.Overrides.Default - ] - - auth_routes_for Orcasite.Accounts.User, to: OrcasiteWeb.AuthController - end - scope "/" do if Mix.env() == :dev do pipe_through(:nextjs) From 1e9d9d28a2f1825bef8d331cbcd2a5062a2c2495 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 22 Nov 2023 11:41:38 -0800 Subject: [PATCH 10/50] Move backend password reset up in router --- server/lib/orcasite/accounts/user.ex | 1 - server/lib/orcasite_web/router.ex | 18 +++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index 741c15cb..e6467888 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -37,7 +37,6 @@ defmodule Orcasite.Accounts.User do resettable do sender fn user, token, opts -> Task.Supervisor.async_nolink(Orcasite.TaskSupervisor, fn -> - IO.inspect("Sending password reset email to #{user.email}") Orcasite.Accounts.Email.reset_password(user, token, opts) |> Orcasite.Mailer.deliver() end) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 063b26dd..13e46251 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -93,13 +93,7 @@ defmodule OrcasiteWeb.Router do end scope "/" do - pipe_through [:browser, :require_admin] - live_dashboard "/admin/dashboard", metrics: OrcasiteWeb.Telemetry - - sign_in_route( - path: "/admin/sign-in", - overrides: [OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] - ) + pipe_through :browser reset_route path: "/admin/password-reset", overrides: [ @@ -108,6 +102,16 @@ defmodule OrcasiteWeb.Router do ] auth_routes_for Orcasite.Accounts.User, to: OrcasiteWeb.AuthController, path: "/admin" + end + + scope "/" do + pipe_through [:browser, :require_admin] + live_dashboard "/admin/dashboard", metrics: OrcasiteWeb.Telemetry + + sign_in_route( + path: "/admin/sign-in", + overrides: [OrcasiteWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] + ) sign_out_route OrcasiteWeb.AuthController, "/admin/sign-out" ash_admin "/admin" From 032c579048a9b3c0ca59c4d8a1523e22bbfbda8f Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 23 Nov 2023 23:19:50 -0800 Subject: [PATCH 11/50] Add sign out page --- ui/src/pages/sign-out/index.tsx | 84 +++++++++++++++++++++++++++++++++ ui/src/utils/auth.ts | 8 ++++ 2 files changed, 92 insertions(+) create mode 100644 ui/src/pages/sign-out/index.tsx diff --git a/ui/src/pages/sign-out/index.tsx b/ui/src/pages/sign-out/index.tsx new file mode 100644 index 00000000..ee0f0bd4 --- /dev/null +++ b/ui/src/pages/sign-out/index.tsx @@ -0,0 +1,84 @@ +import { Box, Paper, Typography } from "@mui/material"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import Header from "@/components/Header"; +import type { NextPageWithLayout } from "@/pages/_app"; +import logo from "@/public/wordmark/wordmark-teal.svg"; +import { clearAuthToken, clearCurrentUser } from "@/utils/auth"; + +const SignInPage: NextPageWithLayout = () => { + const router = useRouter(); + + useEffect(() => { + clearCurrentUser(); + clearAuthToken(); + router.push("/"); + }, [router]); + + return ( +
+ + Sign out | Orcasound + + +
+ +
+ + + + Orcasound + + + + + Signing out... + + + + + +
+
+ ); +}; + +export default SignInPage; diff --git a/ui/src/utils/auth.ts b/ui/src/utils/auth.ts index 402f8e45..207585fd 100644 --- a/ui/src/utils/auth.ts +++ b/ui/src/utils/auth.ts @@ -1,5 +1,13 @@ import { User } from "@/graphql/generated"; +export const clearCurrentUser = () => { + localStorage.removeItem("orcasound:user"); +}; + +export const clearAuthToken = () => { + localStorage.removeItem("orcasound:auth_token"); +}; + export const setCurrentUser = (user: User) => { localStorage.setItem("orcasound:user", JSON.stringify(user)); return user; From 2fbf650b6ae61ea802751d4ab1d8212781612ade Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Sun, 26 Nov 2023 03:28:50 +0000 Subject: [PATCH 12/50] Move register and sign-in pages to top level --- ui/src/pages/{register/index.tsx => register.tsx} | 0 ui/src/pages/{sign-in/index.tsx => sign-in.tsx} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename ui/src/pages/{register/index.tsx => register.tsx} (100%) rename ui/src/pages/{sign-in/index.tsx => sign-in.tsx} (100%) diff --git a/ui/src/pages/register/index.tsx b/ui/src/pages/register.tsx similarity index 100% rename from ui/src/pages/register/index.tsx rename to ui/src/pages/register.tsx diff --git a/ui/src/pages/sign-in/index.tsx b/ui/src/pages/sign-in.tsx similarity index 100% rename from ui/src/pages/sign-in/index.tsx rename to ui/src/pages/sign-in.tsx From 74a020facfc371b17d67ef5c24d45e11f96e51ce Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Sun, 26 Nov 2023 04:21:51 +0000 Subject: [PATCH 13/50] Move auth page layout into separate component --- ui/src/components/layouts/AuthLayout.tsx | 64 ++++++++++++++ ui/src/components/{ => layouts}/MapLayout.tsx | 4 +- ui/src/pages/dynamic/[feed].tsx | 2 +- ui/src/pages/index.tsx | 2 +- ui/src/pages/learn.tsx | 2 +- ui/src/pages/listen/[feed].tsx | 5 +- ui/src/pages/listen/index.tsx | 2 +- ui/src/pages/password-reset/[token].tsx | 75 +++-------------- ui/src/pages/password-reset/index.tsx | 71 +++------------- ui/src/pages/register.tsx | 84 +++++-------------- ui/src/pages/sign-in.tsx | 60 ++----------- 11 files changed, 128 insertions(+), 243 deletions(-) create mode 100644 ui/src/components/layouts/AuthLayout.tsx rename ui/src/components/{ => layouts}/MapLayout.tsx (98%) diff --git a/ui/src/components/layouts/AuthLayout.tsx b/ui/src/components/layouts/AuthLayout.tsx new file mode 100644 index 00000000..e62dd837 --- /dev/null +++ b/ui/src/components/layouts/AuthLayout.tsx @@ -0,0 +1,64 @@ +import { Box, Paper } from "@mui/material"; +import Image from "next/image"; +import { ReactElement } from "react"; + +import Header from "@/components/Header"; +import logo from "@/public/wordmark/wordmark-teal.svg"; + +function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + +
+ + + + Orcasound + + {children} + + + + ); +} + +export function getAuthLayout(page: ReactElement) { + return {page}; +} diff --git a/ui/src/components/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx similarity index 98% rename from ui/src/components/MapLayout.tsx rename to ui/src/components/layouts/MapLayout.tsx index 9f284865..ce3abc24 100644 --- a/ui/src/components/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -11,9 +11,9 @@ import Header from "@/components/Header"; import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; -import Player, { PlayerSpacer } from "./Player"; +import Player, { PlayerSpacer } from "../Player"; -const MapWithNoSSR = dynamic(() => import("./Map"), { +const MapWithNoSSR = dynamic(() => import("../Map"), { ssr: false, }); diff --git a/ui/src/pages/dynamic/[feed].tsx b/ui/src/pages/dynamic/[feed].tsx index a34dc3b6..ad17d5fa 100644 --- a/ui/src/pages/dynamic/[feed].tsx +++ b/ui/src/pages/dynamic/[feed].tsx @@ -3,8 +3,8 @@ import { Box, Breadcrumbs, Container, Typography } from "@mui/material"; import Head from "next/head"; import { useRouter } from "next/router"; +import { getMapLayout } from "@/components/layouts/MapLayout"; import Link from "@/components/Link"; -import { getMapLayout } from "@/components/MapLayout"; import type { NextPageWithLayout } from "@/pages/_app"; const FeedPage: NextPageWithLayout = () => { diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 6a21b734..f52417c8 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -2,8 +2,8 @@ import { GraphicEq, PlayLessonOutlined } from "@mui/icons-material"; import { Box, Button, Container, Typography } from "@mui/material"; import Head from "next/head"; +import { getMapLayout } from "@/components/layouts/MapLayout"; import Link from "@/components/Link"; -import { getMapLayout } from "@/components/MapLayout"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; diff --git a/ui/src/pages/learn.tsx b/ui/src/pages/learn.tsx index af253ebc..2f12495e 100644 --- a/ui/src/pages/learn.tsx +++ b/ui/src/pages/learn.tsx @@ -8,8 +8,8 @@ import { } from "@mui/material"; import Head from "next/head"; +import { getMapLayout } from "@/components/layouts/MapLayout"; import Link from "@/components/Link"; -import { getMapLayout } from "@/components/MapLayout"; import type { NextPageWithLayout } from "@/pages/_app"; const examples = [ diff --git a/ui/src/pages/listen/[feed].tsx b/ui/src/pages/listen/[feed].tsx index dba645ce..0781333a 100644 --- a/ui/src/pages/listen/[feed].tsx +++ b/ui/src/pages/listen/[feed].tsx @@ -5,8 +5,11 @@ import Head from "next/head"; import Image from "next/legacy/image"; import { useRouter } from "next/router"; +import { + getMapLayout, + getMapStaticProps, +} from "@/components/layouts/MapLayout"; import Link from "@/components/Link"; -import { getMapLayout, getMapStaticProps } from "@/components/MapLayout"; import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; diff --git a/ui/src/pages/listen/index.tsx b/ui/src/pages/listen/index.tsx index 56de399a..0c18d98c 100644 --- a/ui/src/pages/listen/index.tsx +++ b/ui/src/pages/listen/index.tsx @@ -3,7 +3,7 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; import Head from "next/head"; import FeedCard from "@/components/FeedCard"; -import { getMapLayout } from "@/components/MapLayout"; +import { getMapLayout } from "@/components/layouts/MapLayout"; import { useFeedsQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx index bffb44e4..ef8f7eec 100644 --- a/ui/src/pages/password-reset/[token].tsx +++ b/ui/src/pages/password-reset/[token].tsx @@ -1,14 +1,11 @@ -import { Box, Paper } from "@mui/material"; import Head from "next/head"; -import Image from "next/image"; import { useRouter } from "next/router"; import { useState } from "react"; import ResetPasswordForm from "@/components/Auth/ResetPasswordForm"; -import Header from "@/components/Header"; +import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, useResetPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import logo from "@/public/wordmark/wordmark-teal.svg"; import { setAuthToken, setCurrentUser } from "@/utils/auth"; const PasswordResetPage: NextPageWithLayout = () => { @@ -53,67 +50,21 @@ const PasswordResetPage: NextPageWithLayout = () => {
- -
- - - - Orcasound - - - submitResetPassword.mutate({ - password, - passwordConfirmation, - resetToken: token, - }) - } - errors={errors} - /> - - - + + submitResetPassword.mutate({ + password, + passwordConfirmation, + resetToken: token, + }) + } + errors={errors} + />
); }; +PasswordResetPage.getLayout = getAuthLayout; + export default PasswordResetPage; diff --git a/ui/src/pages/password-reset/index.tsx b/ui/src/pages/password-reset/index.tsx index 1391c131..7f91a533 100644 --- a/ui/src/pages/password-reset/index.tsx +++ b/ui/src/pages/password-reset/index.tsx @@ -1,14 +1,11 @@ -import { Box, Paper } from "@mui/material"; import Head from "next/head"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; import ResetPasswordRequestForm from "@/components/Auth/ResetPasswordRequestForm"; -import Header from "@/components/Header"; +import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useRequestPasswordResetMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import logo from "@/public/wordmark/wordmark-teal.svg"; const PasswordResetRequestPage: NextPageWithLayout = () => { const router = useRouter(); @@ -36,65 +33,19 @@ const PasswordResetRequestPage: NextPageWithLayout = () => {
- -
- - - - Orcasound - - - submitPasswordResetRequest.mutate({ - email, - }) - } - message={message} - /> - - - + + submitPasswordResetRequest.mutate({ + email, + }) + } + message={message} + />
); }; +PasswordResetRequestPage.getLayout = getAuthLayout; + export default PasswordResetRequestPage; diff --git a/ui/src/pages/register.tsx b/ui/src/pages/register.tsx index 9457e650..5b84f931 100644 --- a/ui/src/pages/register.tsx +++ b/ui/src/pages/register.tsx @@ -1,18 +1,15 @@ -import { Box, Paper } from "@mui/material"; import Head from "next/head"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; import RegisterForm from "@/components/Auth/RegisterForm"; -import Header from "@/components/Header"; +import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, User, useRegisterWithPasswordMutation, } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import logo from "@/public/wordmark/wordmark-teal.svg"; import { setAuthToken, setCurrentUser } from "@/utils/auth"; const RegisterPage: NextPageWithLayout = () => { @@ -57,68 +54,29 @@ const RegisterPage: NextPageWithLayout = () => {
- -
- - - - Orcasound - - - submitRegister.mutate({ - firstName, - lastName, - email, - password, - passwordConfirmation, - }) - } - errors={errors} - /> - - - + + submitRegister.mutate({ + firstName, + lastName, + email, + password, + passwordConfirmation, + }) + } + errors={errors} + />
); }; +RegisterPage.getLayout = getAuthLayout; + export default RegisterPage; diff --git a/ui/src/pages/sign-in.tsx b/ui/src/pages/sign-in.tsx index 44b6ced6..56f449a6 100644 --- a/ui/src/pages/sign-in.tsx +++ b/ui/src/pages/sign-in.tsx @@ -1,14 +1,11 @@ -import { Box, Paper } from "@mui/material"; import Head from "next/head"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; import SignInForm from "@/components/Auth/SignInForm"; -import Header from "@/components/Header"; +import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useSignInWithPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import logo from "@/public/wordmark/wordmark-teal.svg"; import { setAuthToken, setCurrentUser } from "@/utils/auth"; const SignInPage: NextPageWithLayout = () => { @@ -58,56 +55,17 @@ const SignInPage: NextPageWithLayout = () => {
- -
- - - - Orcasound - - - submitSignIn.mutate({ email, password }) - } - errors={errors} - /> - - - + + submitSignIn.mutate({ email, password }) + } + errors={errors} + />
); }; +SignInPage.getLayout = getAuthLayout; + export default SignInPage; From e721149bacc20c6158de25e968c2730447effeec Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Sun, 26 Nov 2023 05:19:02 +0000 Subject: [PATCH 14/50] Move reports pages layout into separate component --- ui/src/components/layouts/ReportsLayout.tsx | 31 +++ ui/src/pages/reports/[candidateId].tsx | 75 ++---- ui/src/pages/reports/index.tsx | 248 +++++++++----------- 3 files changed, 170 insertions(+), 184 deletions(-) create mode 100644 ui/src/components/layouts/ReportsLayout.tsx diff --git a/ui/src/components/layouts/ReportsLayout.tsx b/ui/src/components/layouts/ReportsLayout.tsx new file mode 100644 index 00000000..625a842a --- /dev/null +++ b/ui/src/components/layouts/ReportsLayout.tsx @@ -0,0 +1,31 @@ +import { Box, Container } from "@mui/material"; +import { ReactElement } from "react"; + +import Header from "@/components/Header"; + +function ReportsLayout({ children }: { children: React.ReactNode }) { + return ( + +
+ + {children} + + + ); +} + +export function getReportsLayout(page: ReactElement) { + return {page}; +} diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index 86e9c317..01177415 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -1,16 +1,9 @@ -import { - Box, - Breadcrumbs, - Container, - Link, - Paper, - Typography, -} from "@mui/material"; +import { Box, Breadcrumbs, Link, Paper, Typography } from "@mui/material"; import Head from "next/head"; import { useRouter } from "next/router"; import DetectionsTable from "@/components/DetectionsTable"; -import Header from "@/components/Header"; +import { getReportsLayout } from "@/components/layouts/ReportsLayout"; import { useCandidateQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; @@ -33,51 +26,33 @@ const CandidatePage: NextPageWithLayout = () => { Report {candidateId} | Orcasound
- -
- - - - - - Reports - - {candidate?.id} - - - - - Detections - - {candidate?.id} - - {candidate && ( - - )} - - - + + + Reports + + {candidate?.id} + + + + + Detections + + {candidate?.id} + + {candidate && ( + + )} - +
); }; +CandidatePage.getLayout = getReportsLayout; + export default CandidatePage; diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index 1de4fb83..5596a873 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -1,7 +1,6 @@ import { Box, Button, - Container, Modal, Paper, Table, @@ -19,7 +18,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import DetectionsTable from "@/components/DetectionsTable"; -import Header from "@/components/Header"; +import { getReportsLayout } from "@/components/layouts/ReportsLayout"; import { CandidatesQuery, useCandidatesQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; @@ -86,144 +85,125 @@ const DetectionsPage: NextPageWithLayout = () => {
- -
- - -

Reports

+

Reports

- - { - setDetectionModalOpen(false); - router.back(); - }} - className="p-4" - > - - - - Detections - - Candidate {selectedCandidate?.id} - - {selectedCandidate && ( - - )} - - - - - - - - ID - Node - Detections - Timestamp - Categories - Descriptions - Actions - - - - {candidates.map((candidate) => ( - - {candidate.id} - - {candidate.feed.slug} - - - {candidate.detectionCount} - - - {formatTimestamp(candidate.minTime)} - - - {Object.entries(getCategoryCounts(candidate)) - .map( - ([category, count]) => - `${category.toLowerCase()} [${count}]`, - ) - .join(", ")}{" "} - - - {candidate.detections - .map((d) => d.description) - .filter( - (d) => typeof d !== "undefined" && d !== null, - ) - .slice(0, 3) - .join(", ")} - - - {/* */} - - - - - - ))} - - {candidatesQuery.isSuccess && ( - - - { - setPage(pg); - }} - onRowsPerPageChange={(e) => { - setRowsPerPage(Number(e.target.value)); - }} - rowsPerPageOptions={[10, 50, 100, 1000]} - /> - - + + { + setDetectionModalOpen(false); + router.back(); + }} + className="p-4" + > + + + + Detections + + Candidate {selectedCandidate?.id} + + {selectedCandidate && ( + )} -
+
- - - + + + + + + ID + Node + Detections + Timestamp + Categories + Descriptions + Actions + + + + {candidates.map((candidate) => ( + + {candidate.id} + + {candidate.feed.slug} + + + {candidate.detectionCount} + + + {formatTimestamp(candidate.minTime)} + + + {Object.entries(getCategoryCounts(candidate)) + .map( + ([category, count]) => + `${category.toLowerCase()} [${count}]`, + ) + .join(", ")}{" "} + + + {candidate.detections + .map((d) => d.description) + .filter((d) => typeof d !== "undefined" && d !== null) + .slice(0, 3) + .join(", ")} + + + {/* */} + + + + + + ))} + + {candidatesQuery.isSuccess && ( + + + { + setPage(pg); + }} + onRowsPerPageChange={(e) => { + setRowsPerPage(Number(e.target.value)); + }} + rowsPerPageOptions={[10, 50, 100, 1000]} + /> + + + )} +
+
); }; +DetectionsPage.getLayout = getReportsLayout; + export default DetectionsPage; From 4069e05d1eb7c2a90df28b0507480009e4e4be4a Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Sun, 26 Nov 2023 05:19:43 +0000 Subject: [PATCH 15/50] Refactor types for client --- ui/src/graphql/client.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/src/graphql/client.ts b/ui/src/graphql/client.ts index d7acaae3..0c0dac5c 100644 --- a/ui/src/graphql/client.ts +++ b/ui/src/graphql/client.ts @@ -22,11 +22,11 @@ export function fetcher( variables?: TVariables, options?: RequestInit["headers"], ) { - return async (): Promise => { - const res = await fetch(endpointUrl as string, { + return async () => { + const res = await fetch(endpointUrl, { method: "POST", ...fetchParams(), - ...(options || {}), + ...options, body: JSON.stringify({ query, variables }), }); @@ -34,10 +34,9 @@ export function fetcher( if (json.errors) { const { message } = json.errors[0]; - throw new Error(message); } - return json.data; + return json.data as TData; }; } From 8ad21339f2501a9a6432f6e3f8a30fa700b9bffe Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Sun, 26 Nov 2023 06:08:31 +0000 Subject: [PATCH 16/50] Rename auth folder to lowercase Auth isn't a component by itself --- ui/src/components/{Auth => auth}/RegisterForm.tsx | 0 ui/src/components/{Auth => auth}/ResetPasswordForm.tsx | 0 ui/src/components/{Auth => auth}/ResetPasswordRequestForm.tsx | 0 ui/src/components/{Auth => auth}/SignInForm.tsx | 0 ui/src/pages/password-reset/[token].tsx | 2 +- ui/src/pages/password-reset/index.tsx | 2 +- ui/src/pages/register.tsx | 2 +- ui/src/pages/sign-in.tsx | 2 +- 8 files changed, 4 insertions(+), 4 deletions(-) rename ui/src/components/{Auth => auth}/RegisterForm.tsx (100%) rename ui/src/components/{Auth => auth}/ResetPasswordForm.tsx (100%) rename ui/src/components/{Auth => auth}/ResetPasswordRequestForm.tsx (100%) rename ui/src/components/{Auth => auth}/SignInForm.tsx (100%) diff --git a/ui/src/components/Auth/RegisterForm.tsx b/ui/src/components/auth/RegisterForm.tsx similarity index 100% rename from ui/src/components/Auth/RegisterForm.tsx rename to ui/src/components/auth/RegisterForm.tsx diff --git a/ui/src/components/Auth/ResetPasswordForm.tsx b/ui/src/components/auth/ResetPasswordForm.tsx similarity index 100% rename from ui/src/components/Auth/ResetPasswordForm.tsx rename to ui/src/components/auth/ResetPasswordForm.tsx diff --git a/ui/src/components/Auth/ResetPasswordRequestForm.tsx b/ui/src/components/auth/ResetPasswordRequestForm.tsx similarity index 100% rename from ui/src/components/Auth/ResetPasswordRequestForm.tsx rename to ui/src/components/auth/ResetPasswordRequestForm.tsx diff --git a/ui/src/components/Auth/SignInForm.tsx b/ui/src/components/auth/SignInForm.tsx similarity index 100% rename from ui/src/components/Auth/SignInForm.tsx rename to ui/src/components/auth/SignInForm.tsx diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx index ef8f7eec..8c49da5c 100644 --- a/ui/src/pages/password-reset/[token].tsx +++ b/ui/src/pages/password-reset/[token].tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import { useRouter } from "next/router"; import { useState } from "react"; -import ResetPasswordForm from "@/components/Auth/ResetPasswordForm"; +import ResetPasswordForm from "@/components/auth/ResetPasswordForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, useResetPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; diff --git a/ui/src/pages/password-reset/index.tsx b/ui/src/pages/password-reset/index.tsx index 7f91a533..2f18a6df 100644 --- a/ui/src/pages/password-reset/index.tsx +++ b/ui/src/pages/password-reset/index.tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import ResetPasswordRequestForm from "@/components/Auth/ResetPasswordRequestForm"; +import ResetPasswordRequestForm from "@/components/auth/ResetPasswordRequestForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useRequestPasswordResetMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; diff --git a/ui/src/pages/register.tsx b/ui/src/pages/register.tsx index 5b84f931..d252f620 100644 --- a/ui/src/pages/register.tsx +++ b/ui/src/pages/register.tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import RegisterForm from "@/components/Auth/RegisterForm"; +import RegisterForm from "@/components/auth/RegisterForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, diff --git a/ui/src/pages/sign-in.tsx b/ui/src/pages/sign-in.tsx index 56f449a6..cf297067 100644 --- a/ui/src/pages/sign-in.tsx +++ b/ui/src/pages/sign-in.tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import SignInForm from "@/components/Auth/SignInForm"; +import SignInForm from "@/components/auth/SignInForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useSignInWithPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; From f454c0effa1ff623e7e2f6ca60f87d25f2ddecea Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Mon, 27 Nov 2023 03:33:48 +0000 Subject: [PATCH 17/50] Refactor SignInForm --- ui/src/components/auth/SignInForm.tsx | 26 ++++++++++++-------------- ui/src/pages/sign-in.tsx | 7 +------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/ui/src/components/auth/SignInForm.tsx b/ui/src/components/auth/SignInForm.tsx index f473702c..33cf9f79 100644 --- a/ui/src/components/auth/SignInForm.tsx +++ b/ui/src/components/auth/SignInForm.tsx @@ -1,19 +1,21 @@ -import { Alert, Box, Button, Link, TextField } from "@mui/material"; -import NextLink from "next/link"; -import React, { useState } from "react"; +import { Alert, Box, Button, TextField } from "@mui/material"; +import { FormEvent, useState } from "react"; -interface SignInFormProps { - onSubmit: (email: string, password: string) => void; +import Link from "@/components/Link"; +import { SignInWithPasswordMutationVariables } from "@/graphql/generated"; + +type SignInFormProps = { + onSubmit: (args: SignInWithPasswordMutationVariables) => void; errors?: string[]; -} +}; -const SignInForm: React.FC = ({ onSubmit, errors }) => { +export default function SignInForm({ onSubmit, errors }: SignInFormProps) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = (event: FormEvent) => { event.preventDefault(); - onSubmit(email, password); + onSubmit({ email, password }); }; return ( @@ -87,7 +89,6 @@ const SignInForm: React.FC = ({ onSubmit, errors }) => { = ({ onSubmit, errors }) => { Forgot your password? = ({ onSubmit, errors }) => { ); -}; +} const errorCodeToMessage = (error: string) => { if (error === "invalid_credentials") { @@ -139,5 +139,3 @@ const errorCodeToMessage = (error: string) => { return `An unknown error occurred. Please try again and let us know if this keeps happening.`; } }; - -export default SignInForm; diff --git a/ui/src/pages/sign-in.tsx b/ui/src/pages/sign-in.tsx index cf297067..32b88b7c 100644 --- a/ui/src/pages/sign-in.tsx +++ b/ui/src/pages/sign-in.tsx @@ -55,12 +55,7 @@ const SignInPage: NextPageWithLayout = () => {
- - submitSignIn.mutate({ email, password }) - } - errors={errors} - /> +
); From 8bfcf5da0ff14e57f1c888adfced88319abb941d Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Nov 2023 12:08:08 -0800 Subject: [PATCH 18/50] Use theme.palette.augmentColor for accent colors --- ui/src/styles/theme.ts | 44 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/ui/src/styles/theme.ts b/ui/src/styles/theme.ts index 5f1f4b40..c646747c 100644 --- a/ui/src/styles/theme.ts +++ b/ui/src/styles/theme.ts @@ -57,26 +57,30 @@ const theme = createTheme({ contrastText: "#ffffff", }, }), - accent1: { - main: "#002f49", - dark: "#002a42", - light: "#50c1ff", - }, - accent2: { - main: "#9b9b9b", - dark: "#8b8b8b", - light: "#d7d7d7", - }, - accent3: { - main: "#a4d3d1", - dark: "#8bc7c4", - light: "#dbeded", - }, - accent4: { - main: "#258dad", - dark: "#217f9c", - light: "#9cd8ea", - }, + accent1: helperTheme.palette.augmentColor({ + color: { + main: "#002f49", + }, + name: "accent1", + }), + accent2: helperTheme.palette.augmentColor({ + color: { + main: "#9b9b9b", + }, + name: "accent2", + }), + accent3: helperTheme.palette.augmentColor({ + color: { + main: "#a4d3d1", + }, + name: "accent3", + }), + accent4: helperTheme.palette.augmentColor({ + color: { + main: "#258dad", + }, + name: "accent4", + }), error: { main: "#e9222f", }, From c7fe82847b998a6e6671a221b31afe0b051630df Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Nov 2023 12:09:27 -0800 Subject: [PATCH 19/50] Use Container for auth forms --- ui/src/components/layouts/AuthLayout.tsx | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/ui/src/components/layouts/AuthLayout.tsx b/ui/src/components/layouts/AuthLayout.tsx index e62dd837..35a444c8 100644 --- a/ui/src/components/layouts/AuthLayout.tsx +++ b/ui/src/components/layouts/AuthLayout.tsx @@ -1,4 +1,4 @@ -import { Box, Paper } from "@mui/material"; +import { Box, Container, Paper } from "@mui/material"; import Image from "next/image"; import { ReactElement } from "react"; @@ -27,33 +27,33 @@ function AuthLayout({ children }: { children: React.ReactNode }) { alignItems="center" sx={{ flexGrow: 1 }} > - - + - Orcasound - - {children} - + + Orcasound + + {children} + +
); From cb6a47ac6a8a320d323a982f16cbf31f123d220b Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Nov 2023 12:13:07 -0800 Subject: [PATCH 20/50] Remove unnecessary color='primary' --- ui/src/components/auth/SignInForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/components/auth/SignInForm.tsx b/ui/src/components/auth/SignInForm.tsx index 33cf9f79..a5a13382 100644 --- a/ui/src/components/auth/SignInForm.tsx +++ b/ui/src/components/auth/SignInForm.tsx @@ -115,7 +115,6 @@ export default function SignInForm({ onSubmit, errors }: SignInFormProps) { ); -}; +} const errorToString = ( error?: Pick, @@ -157,5 +157,3 @@ const errorToString = ( return `An unknown error occurred. Please try again and let us know if this keeps happening.`; } }; - -export default ResetPasswordForm; diff --git a/ui/src/components/auth/ResetPasswordRequestForm.tsx b/ui/src/components/auth/ResetPasswordRequestForm.tsx index b700f51a..7174ea49 100644 --- a/ui/src/components/auth/ResetPasswordRequestForm.tsx +++ b/ui/src/components/auth/ResetPasswordRequestForm.tsx @@ -1,16 +1,16 @@ import { Alert, Box, Button, Link, TextField } from "@mui/material"; import NextLink from "next/link"; -import { FC, FormEvent, useState } from "react"; +import { FormEvent, useState } from "react"; type ForgotPasswordFormProps = { onSubmit: (email: string) => void; message?: string; -} +}; -const ForgotPasswordForm: FC = ({ +export default function ForgotPasswordForm({ onSubmit, message, -}) => { +}: ForgotPasswordFormProps) { const [email, setEmail] = useState(""); const handleSubmit = (event: FormEvent) => { @@ -96,6 +96,4 @@ const ForgotPasswordForm: FC = ({ ); -}; - -export default ForgotPasswordForm; +} From 990ade1e7d495036c864858c71160b10dd8dcb57 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Nov 2023 12:38:40 -0800 Subject: [PATCH 25/50] Use mutation variable types for auth form annotations --- ui/src/components/auth/RegisterForm.tsx | 21 ++++++++----------- ui/src/components/auth/ResetPasswordForm.tsx | 14 ++++++++++--- .../auth/ResetPasswordRequestForm.tsx | 6 ++++-- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/ui/src/components/auth/RegisterForm.tsx b/ui/src/components/auth/RegisterForm.tsx index 9b17f02d..7a89745a 100644 --- a/ui/src/components/auth/RegisterForm.tsx +++ b/ui/src/components/auth/RegisterForm.tsx @@ -2,18 +2,15 @@ import { Alert, Box, Button, Link, TextField } from "@mui/material"; import NextLink from "next/link"; import { FormEvent, useState } from "react"; -import { MutationError } from "@/graphql/generated"; +import { + MutationError, + RegisterWithPasswordMutationVariables, +} from "@/graphql/generated"; type RegisterFormProps = { - onSubmit: ( - firstName: string, - lastName: string, - email: string, - password: string, - passwordConfirmation: string, - ) => void; + onSubmit: (args: RegisterWithPasswordMutationVariables) => void; errors: MutationError[]; -} +}; export default function RegisterForm({ onSubmit, errors }: RegisterFormProps) { const [firstName, setFirstName] = useState(""); @@ -24,7 +21,7 @@ export default function RegisterForm({ onSubmit, errors }: RegisterFormProps) { const handleSubmit = (event: FormEvent) => { event.preventDefault(); - onSubmit(firstName, lastName, email, password, passwordConfirmation); + onSubmit({ firstName, lastName, email, password, passwordConfirmation }); }; return ( @@ -253,8 +250,8 @@ export default function RegisterForm({ onSubmit, errors }: RegisterFormProps) { ); -}; +} const errorCodeToMessage = (_error: Pick) => { return "An unknown error occurred. Please try again and let us know if this keeps happening."; -}; \ No newline at end of file +}; diff --git a/ui/src/components/auth/ResetPasswordForm.tsx b/ui/src/components/auth/ResetPasswordForm.tsx index 68323519..547bf65e 100644 --- a/ui/src/components/auth/ResetPasswordForm.tsx +++ b/ui/src/components/auth/ResetPasswordForm.tsx @@ -1,10 +1,18 @@ import { Alert, Button, TextField } from "@mui/material"; import { FormEvent, useState } from "react"; -import { MutationError } from "@/graphql/generated"; +import { + MutationError, + ResetPasswordMutationVariables, +} from "@/graphql/generated"; type ResetPasswordFormProps = { - onSubmit: (password: string, passworConfirmation: string) => void; + onSubmit: ( + args: Pick< + ResetPasswordMutationVariables, + "password" | "passwordConfirmation" + >, + ) => void; errors: MutationError[]; }; @@ -17,7 +25,7 @@ export default function ResetPasswordForm({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); - onSubmit(password, passwordConfirmation); + onSubmit({ password, passwordConfirmation }); }; return ( diff --git a/ui/src/components/auth/ResetPasswordRequestForm.tsx b/ui/src/components/auth/ResetPasswordRequestForm.tsx index 7174ea49..04e502a3 100644 --- a/ui/src/components/auth/ResetPasswordRequestForm.tsx +++ b/ui/src/components/auth/ResetPasswordRequestForm.tsx @@ -2,8 +2,10 @@ import { Alert, Box, Button, Link, TextField } from "@mui/material"; import NextLink from "next/link"; import { FormEvent, useState } from "react"; +import { RequestPasswordResetMutationVariables } from "@/graphql/generated"; + type ForgotPasswordFormProps = { - onSubmit: (email: string) => void; + onSubmit: (args: RequestPasswordResetMutationVariables) => void; message?: string; }; @@ -15,7 +17,7 @@ export default function ForgotPasswordForm({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); - onSubmit(email); + onSubmit({ email }); setEmail(""); }; From fb0af0485ed887fc70b09e069fd1fd6c0aecc60c Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Nov 2023 12:40:43 -0800 Subject: [PATCH 26/50] Use our Link components --- ui/src/components/auth/RegisterForm.tsx | 6 ++---- ui/src/components/auth/ResetPasswordRequestForm.tsx | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/ui/src/components/auth/RegisterForm.tsx b/ui/src/components/auth/RegisterForm.tsx index 7a89745a..18386ce6 100644 --- a/ui/src/components/auth/RegisterForm.tsx +++ b/ui/src/components/auth/RegisterForm.tsx @@ -1,7 +1,7 @@ -import { Alert, Box, Button, Link, TextField } from "@mui/material"; -import NextLink from "next/link"; +import { Alert, Box, Button, TextField } from "@mui/material"; import { FormEvent, useState } from "react"; +import Link from "@/components/Link"; import { MutationError, RegisterWithPasswordMutationVariables, @@ -209,7 +209,6 @@ export default function RegisterForm({ onSubmit, errors }: RegisterFormProps) { sx={{ marginTop: 2, marginBottom: 1 }} > Date: Wed, 6 Dec 2023 10:14:48 -0800 Subject: [PATCH 27/50] Remove token, use session auth --- .../orcasite_web/graphql/types/accounts.ex | 18 +++++++++--- server/lib/orcasite_web/router.ex | 28 ++++++++++++++----- ui/src/graphql/generated/index.ts | 13 --------- .../mutations/registerWithPassword.graphql | 3 -- .../graphql/mutations/resetPassword.graphql | 1 - .../mutations/signInWithPassword.graphql | 1 - ui/src/pages/password-reset/[token].tsx | 6 +--- ui/src/pages/register.tsx | 9 ++---- ui/src/pages/sign-in.tsx | 15 ++++------ 9 files changed, 43 insertions(+), 51 deletions(-) diff --git a/server/lib/orcasite_web/graphql/types/accounts.ex b/server/lib/orcasite_web/graphql/types/accounts.ex index f4deefbd..b20aa2c1 100644 --- a/server/lib/orcasite_web/graphql/types/accounts.ex +++ b/server/lib/orcasite_web/graphql/types/accounts.ex @@ -9,7 +9,6 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do end object :sign_in_with_password_result do - field :token, :string field :user, :user field :errors, list_of(:mutation_error) end @@ -25,7 +24,6 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do end object :password_reset_result do - field :token, :string field :user, :user field :errors, list_of(:mutation_error) end @@ -36,12 +34,14 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do resolve(fn _, %{input: args}, _ -> with {:ok, user} <- User.sign_in_with_password(args) do - {:ok, %{user: user, token: user.__metadata__.token}} + {:ok, %{user: user}} else {:error, _} -> {:ok, %{errors: [%{code: "invalid_credentials"}]}} end end) + + middleware(&set_current_user/2) end field :request_password_reset, type: :boolean do @@ -78,12 +78,22 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do }, [] ) do - {:ok, %{user: user, token: user.__metadata__.token}} + {:ok, %{user: user}} else {:error, err} -> {:ok, %{errors: Enum.map(err.errors, &AshGraphql.Error.to_error/1)}} end end) + + middleware(&set_current_user/2) + end + end + + defp set_current_user(resolution, _) do + with %{value: %{user: user}} <- resolution do + Map.update!(resolution, :context, fn ctx -> + Map.put(ctx, :current_user, user) + end) end end end diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 13e46251..3534fe56 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -49,7 +49,8 @@ defmodule OrcasiteWeb.Router do pipeline :graphql do plug(:parsers) - plug :load_from_bearer + plug :fetch_session + plug :load_from_session plug AshGraphql.Plug end @@ -72,7 +73,11 @@ defmodule OrcasiteWeb.Router do scope "/graphql" do pipe_through(:graphql) - forward("/", Absinthe.Plug, schema: OrcasiteWeb.Schema, json_codec: Jason) + forward("/", Absinthe.Plug, + schema: OrcasiteWeb.Schema, + json_codec: Jason, + before_send: {__MODULE__, :absinthe_before_send} + ) end # For the GraphiQL interactive interface, a must-have for happy frontend devs. @@ -167,11 +172,20 @@ defmodule OrcasiteWeb.Router do end end - defp set_current_user_as_actor(%{assigns: %{current_user: actor}} = conn, _opts) do - conn - |> update_in([Access.key!(:assigns)], &Map.drop(&1, [:current_user])) - |> Ash.PlugHelpers.set_actor(actor) + def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do + if user = blueprint.execution.context[:current_user] do + IO.inspect(user, label: "Setting current user") + + conn + |> assign(:current_user, user) + |> AshAuthentication.Plug.Helpers.store_in_session(user) + |> AshAuthentication.Plug.Helpers.set_actor(:user) + else + conn + end end - defp set_current_user_as_actor(conn, _opts), do: conn + def absinthe_before_send(conn, _) do + conn + end end diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 044e210f..ffe91c20 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -415,7 +415,6 @@ export type PasswordResetInput = { export type PasswordResetResult = { __typename?: "PasswordResetResult"; errors?: Maybe>>; - token?: Maybe; user?: Maybe; }; @@ -535,7 +534,6 @@ export type SignInWithPasswordInput = { export type SignInWithPasswordResult = { __typename?: "SignInWithPasswordResult"; errors?: Maybe>>; - token?: Maybe; user?: Maybe; }; @@ -653,10 +651,6 @@ export type RegisterWithPasswordMutation = { __typename?: "RootMutationType"; registerWithPassword?: { __typename?: "RegisterWithPasswordResult"; - metadata?: { - __typename?: "RegisterWithPasswordMetadata"; - token: string; - } | null; result?: { __typename?: "User"; id: string; @@ -695,7 +689,6 @@ export type ResetPasswordMutation = { __typename?: "RootMutationType"; resetPassword?: { __typename?: "PasswordResetResult"; - token?: string | null; errors?: Array<{ __typename?: "MutationError"; code?: string | null; @@ -724,7 +717,6 @@ export type SignInWithPasswordMutation = { __typename?: "RootMutationType"; signInWithPassword?: { __typename?: "SignInWithPasswordResult"; - token?: string | null; user?: { __typename?: "User"; id: string; @@ -877,9 +869,6 @@ export const RegisterWithPasswordDocument = ` registerWithPassword( input: {email: $email, password: $password, passwordConfirmation: $passwordConfirmation, firstName: $firstName, lastName: $lastName} ) { - metadata { - token - } result { id email @@ -979,7 +968,6 @@ export const ResetPasswordDocument = ` resetPassword( input: {password: $password, passwordConfirmation: $passwordConfirmation, resetToken: $resetToken} ) { - token errors { code fields @@ -1033,7 +1021,6 @@ useResetPasswordMutation.fetcher = ( export const SignInWithPasswordDocument = ` mutation signInWithPassword($email: String!, $password: String!) { signInWithPassword(input: {email: $email, password: $password}) { - token user { id email diff --git a/ui/src/graphql/mutations/registerWithPassword.graphql b/ui/src/graphql/mutations/registerWithPassword.graphql index 629e6c40..2b2da59c 100644 --- a/ui/src/graphql/mutations/registerWithPassword.graphql +++ b/ui/src/graphql/mutations/registerWithPassword.graphql @@ -14,9 +14,6 @@ mutation registerWithPassword( lastName: $lastName } ) { - metadata { - token - } result { id email diff --git a/ui/src/graphql/mutations/resetPassword.graphql b/ui/src/graphql/mutations/resetPassword.graphql index 203a0c7a..30f7a5b2 100644 --- a/ui/src/graphql/mutations/resetPassword.graphql +++ b/ui/src/graphql/mutations/resetPassword.graphql @@ -10,7 +10,6 @@ mutation resetPassword( resetToken: $resetToken } ) { - token errors { code fields diff --git a/ui/src/graphql/mutations/signInWithPassword.graphql b/ui/src/graphql/mutations/signInWithPassword.graphql index 7fc1bd11..2ee0949e 100644 --- a/ui/src/graphql/mutations/signInWithPassword.graphql +++ b/ui/src/graphql/mutations/signInWithPassword.graphql @@ -1,6 +1,5 @@ mutation signInWithPassword($email: String!, $password: String!) { signInWithPassword(input: { email: $email, password: $password }) { - token user { id email diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx index 8c49da5c..bddbffa5 100644 --- a/ui/src/pages/password-reset/[token].tsx +++ b/ui/src/pages/password-reset/[token].tsx @@ -6,7 +6,7 @@ import ResetPasswordForm from "@/components/auth/ResetPasswordForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, useResetPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import { setAuthToken, setCurrentUser } from "@/utils/auth"; +import { setCurrentUser } from "@/utils/auth"; const PasswordResetPage: NextPageWithLayout = () => { const router = useRouter(); @@ -28,10 +28,6 @@ const PasswordResetPage: NextPageWithLayout = () => { ); } - if (token) { - setAuthToken(token); - } - if (user) { setCurrentUser(user); router.push("/"); diff --git a/ui/src/pages/register.tsx b/ui/src/pages/register.tsx index d252f620..a46c9c33 100644 --- a/ui/src/pages/register.tsx +++ b/ui/src/pages/register.tsx @@ -10,7 +10,7 @@ import { useRegisterWithPasswordMutation, } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import { setAuthToken, setCurrentUser } from "@/utils/auth"; +import { setCurrentUser } from "@/utils/auth"; const RegisterPage: NextPageWithLayout = () => { const router = useRouter(); @@ -23,8 +23,7 @@ const RegisterPage: NextPageWithLayout = () => { }, onSuccess: ({ registerWithPassword }) => { if (registerWithPassword) { - const { metadata, result: user, errors } = registerWithPassword; - const token = metadata?.token; + const { result: user, errors } = registerWithPassword; if (errors) { setErrors( @@ -32,10 +31,6 @@ const RegisterPage: NextPageWithLayout = () => { ); } - if (token) { - setAuthToken(token); - } - if (user) { setCurrentUser(user as User); router.push("/"); diff --git a/ui/src/pages/sign-in.tsx b/ui/src/pages/sign-in.tsx index 32b88b7c..43390342 100644 --- a/ui/src/pages/sign-in.tsx +++ b/ui/src/pages/sign-in.tsx @@ -6,7 +6,6 @@ import SignInForm from "@/components/auth/SignInForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useSignInWithPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import { setAuthToken, setCurrentUser } from "@/utils/auth"; const SignInPage: NextPageWithLayout = () => { const router = useRouter(); @@ -19,7 +18,7 @@ const SignInPage: NextPageWithLayout = () => { }, onSuccess: ({ signInWithPassword }) => { if (signInWithPassword) { - const { token, user, errors } = signInWithPassword; + const { user, errors } = signInWithPassword; if (errors && errors?.length > 0) { setErrors( @@ -33,14 +32,10 @@ const SignInPage: NextPageWithLayout = () => { ); } - if (token) { - setAuthToken(token); - } - - if (user) { - setCurrentUser(user); - router.push("/"); - } + // if (user) { + // setCurrentUser(user); + // router.push("/"); + // } } }, onError: (error: Error) => { From 48b682b295c18a0e5803d470e92c96b7d24e88fd Mon Sep 17 00:00:00 2001 From: skanderm Date: Wed, 6 Dec 2023 11:32:32 -0800 Subject: [PATCH 28/50] Authorization policies (#283) * Add currentUser GQL query, basic authorization policies for users, feeds, detections, candidates. * Remove commented out token lifetime * Add missing getCurrentUser gql definition --- server/config/dev.exs | 2 + server/lib/orcasite/accounts.ex | 5 +- server/lib/orcasite/accounts/user.ex | 23 +++++--- server/lib/orcasite/radio.ex | 1 - server/lib/orcasite/radio/candidate.ex | 37 +++++++++---- server/lib/orcasite/radio/detection.ex | 17 +++++- server/lib/orcasite/radio/feed.ex | 13 ++++- ui/src/graphql/generated/index.ts | 55 +++++++++++++++++++ ui/src/graphql/queries/getCurrentUser.graphql | 9 +++ 9 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 ui/src/graphql/queries/getCurrentUser.graphql diff --git a/server/config/dev.exs b/server/config/dev.exs index c2a700dc..cfd2855c 100644 --- a/server/config/dev.exs +++ b/server/config/dev.exs @@ -98,3 +98,5 @@ config :hammer, config :orcasite, OrcasiteWeb.BasicAuth, username: "admin", password: "password" config :ash_graphql, :policies, show_policy_breakdowns?: true +config :orcasite, Orcasite.Radio, graphql: [show_raised_errors?: true] +config :orcasite, Orcasite.Accounts, graphql: [show_raised_errors?: true] diff --git a/server/lib/orcasite/accounts.ex b/server/lib/orcasite/accounts.ex index 6659f7ed..eeb32773 100644 --- a/server/lib/orcasite/accounts.ex +++ b/server/lib/orcasite/accounts.ex @@ -1,5 +1,5 @@ defmodule Orcasite.Accounts do - use Ash.Api, extensions: [AshAdmin.Api] + use Ash.Api, extensions: [AshAdmin.Api, AshGraphql.Api] resources do registry Orcasite.Accounts.Registry @@ -8,4 +8,7 @@ defmodule Orcasite.Accounts do admin do show? true end + + graphql do + end end diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index e6467888..58d23555 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Accounts.User do use Ash.Resource, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication, AshAdmin.Resource, AshGraphql.Resource] + extensions: [AshAuthentication, AshAdmin.Resource, AshGraphql.Resource], + authorizers: [Ash.Policy.Authorizer] postgres do table "users" @@ -54,6 +55,20 @@ defmodule Orcasite.Accounts.User do select_for_senders [:id, :email, :first_name, :last_name] end + policies do + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end + + bypass AshAuthentication.Checks.AshAuthenticationInteraction do + authorize_if always() + end + + policy action(:current_user) do + authorize_if always() + end + end + actions do defaults [:read, :create, :update, :destroy] @@ -93,10 +108,4 @@ defmodule Orcasite.Accounts.User do create :register_with_password, :register_with_password end end - - # policies do - # bypass AshAuthentication.Checks.AshAuthenticationInteraction do - # authorize_if always() - # end - # end end diff --git a/server/lib/orcasite/radio.ex b/server/lib/orcasite/radio.ex index ab6d394b..1a692ac0 100644 --- a/server/lib/orcasite/radio.ex +++ b/server/lib/orcasite/radio.ex @@ -10,7 +10,6 @@ defmodule Orcasite.Radio do end graphql do - authorize? false end json_api do diff --git a/server/lib/orcasite/radio/candidate.ex b/server/lib/orcasite/radio/candidate.ex index 94f0d418..42d09940 100644 --- a/server/lib/orcasite/radio/candidate.ex +++ b/server/lib/orcasite/radio/candidate.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Radio.Candidate do use Ash.Resource, extensions: [AshAdmin.Resource, AshUUID, AshGraphql.Resource], - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] alias Orcasite.Radio.{Detection, Feed} @@ -29,6 +30,30 @@ defmodule Orcasite.Radio.Candidate do update_timestamp :updated_at end + calculations do + calculate :uuid, :string, {Orcasite.Radio.Calculations.DecodeUUID, []} + end + + relationships do + has_many :detections, Detection + + belongs_to :feed, Feed, allow_nil?: false + end + + policies do + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + + policy action_type(:create) do + authorize_if always() + end + end + actions do defaults [:update, :destroy] @@ -92,16 +117,6 @@ defmodule Orcasite.Radio.Candidate do end end - calculations do - calculate :uuid, :string, {Orcasite.Radio.Calculations.DecodeUUID, []} - end - - relationships do - has_many :detections, Detection - - belongs_to :feed, Feed, allow_nil?: false - end - admin do table_columns [:id, :detection_count, :feed, :min_time, :max_time, :inserted_at] end diff --git a/server/lib/orcasite/radio/detection.ex b/server/lib/orcasite/radio/detection.ex index 99ce55ed..d71967ea 100644 --- a/server/lib/orcasite/radio/detection.ex +++ b/server/lib/orcasite/radio/detection.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Radio.Detection do use Ash.Resource, extensions: [AshAdmin.Resource, AshUUID, AshGraphql.Resource], - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] alias Orcasite.Radio.{Feed, Candidate} @@ -49,6 +50,20 @@ defmodule Orcasite.Radio.Detection do belongs_to :feed, Feed end + policies do + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + + policy action_type(:create) do + authorize_if always() + end + end + actions do defaults [:destroy] diff --git a/server/lib/orcasite/radio/feed.ex b/server/lib/orcasite/radio/feed.ex index 4a1b13ce..377de63c 100644 --- a/server/lib/orcasite/radio/feed.ex +++ b/server/lib/orcasite/radio/feed.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Radio.Feed do use Ash.Resource, extensions: [AshAdmin.Resource, AshUUID, AshGraphql.Resource, AshJsonApi.Resource], - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "feeds" @@ -55,6 +56,16 @@ defmodule Orcasite.Radio.Feed do {Orcasite.Radio.Calculations.FeedImageUrl, object: "map.png"} end + policies do + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if always() + end + end + actions do defaults [:destroy] diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index ffe91c20..ff50b477 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -785,6 +785,20 @@ export type CandidateQuery = { } | null; }; +export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never }>; + +export type GetCurrentUserQuery = { + __typename?: "RootQueryType"; + currentUser?: { + __typename?: "User"; + id: string; + firstName?: string | null; + lastName?: string | null; + email: string; + admin?: boolean | null; + } | null; +}; + export type FeedQueryVariables = Exact<{ slug: Scalars["String"]["input"]; }>; @@ -1173,6 +1187,47 @@ useCandidateQuery.fetcher = ( variables, options, ); +export const GetCurrentUserDocument = ` + query getCurrentUser { + currentUser { + id + firstName + lastName + email + admin + } +} + `; +export const useGetCurrentUserQuery = < + TData = GetCurrentUserQuery, + TError = unknown, +>( + variables?: GetCurrentUserQueryVariables, + options?: UseQueryOptions, +) => + useQuery( + variables === undefined + ? ["getCurrentUser"] + : ["getCurrentUser", variables], + fetcher( + GetCurrentUserDocument, + variables, + ), + options, + ); +useGetCurrentUserQuery.document = GetCurrentUserDocument; + +useGetCurrentUserQuery.getKey = (variables?: GetCurrentUserQueryVariables) => + variables === undefined ? ["getCurrentUser"] : ["getCurrentUser", variables]; +useGetCurrentUserQuery.fetcher = ( + variables?: GetCurrentUserQueryVariables, + options?: RequestInit["headers"], +) => + fetcher( + GetCurrentUserDocument, + variables, + options, + ); export const FeedDocument = ` query feed($slug: String!) { feed(slug: $slug) { diff --git a/ui/src/graphql/queries/getCurrentUser.graphql b/ui/src/graphql/queries/getCurrentUser.graphql new file mode 100644 index 00000000..88ef8e22 --- /dev/null +++ b/ui/src/graphql/queries/getCurrentUser.graphql @@ -0,0 +1,9 @@ +query getCurrentUser { + currentUser { + id + firstName + lastName + email + admin + } +} From 7f040ba1359cdc1ea8e61aac8b81c6734a25aac8 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 6 Dec 2023 12:05:38 -0800 Subject: [PATCH 29/50] Fix wrong form arg shape --- server/lib/orcasite_web/router.ex | 26 ++++++++++++++----------- ui/src/components/layouts/MapLayout.tsx | 5 ++++- ui/src/graphql/client.ts | 4 ---- ui/src/pages/password-reset/[token].tsx | 6 ++---- ui/src/pages/password-reset/index.tsx | 7 +------ ui/src/pages/register.tsx | 7 ++----- ui/src/pages/sign-in.tsx | 7 +++---- ui/src/utils/auth.ts | 20 ------------------- 8 files changed, 27 insertions(+), 55 deletions(-) delete mode 100644 ui/src/utils/auth.ts diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 3534fe56..d0fe1ca7 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -51,6 +51,7 @@ defmodule OrcasiteWeb.Router do plug(:parsers) plug :fetch_session plug :load_from_session + plug :set_current_user_as_actor plug AshGraphql.Plug end @@ -135,18 +136,14 @@ defmodule OrcasiteWeb.Router do end scope "/" do - if Mix.env() == :dev do - pipe_through(:nextjs) - get("/*page", OrcasiteWeb.PageController, :index) - else - pipe_through(:nextjs) - ui_port = System.get_env("UI_PORT") || "3000" + pipe_through(:nextjs) + ui_port = System.get_env("UI_PORT") || "3000" - forward("/", ReverseProxyPlug, - upstream: "http://localhost:#{ui_port}", - error_callback: &__MODULE__.log_reverse_proxy_error/1 - ) - end + # TODO: Figure out websocket proxying (ws:// protocl) for /_next/webpack-hmr + forward("/", ReverseProxyPlug, + upstream: "//localhost:#{ui_port}", + error_callback: &__MODULE__.log_reverse_proxy_error/1 + ) end def log_reverse_proxy_error(error) do @@ -188,4 +185,11 @@ defmodule OrcasiteWeb.Router do def absinthe_before_send(conn, _) do conn end + + defp set_current_user_as_actor(%{assigns: %{current_user: actor}} = conn, _opts) do + conn + |> AshAuthentication.Plug.Helpers.set_actor(:user) + end + + defp set_current_user_as_actor(conn, _opts), do: conn end diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index ce3abc24..fa83cf0f 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -8,7 +8,10 @@ import { ReactElement, ReactNode, useEffect, useState } from "react"; import Drawer from "@/components/Drawer"; import Header from "@/components/Header"; -import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; +import { + useFeedQuery, + useFeedsQuery, +} from "@/graphql/generated"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; import Player, { PlayerSpacer } from "../Player"; diff --git a/ui/src/graphql/client.ts b/ui/src/graphql/client.ts index 0c0dac5c..3cabc22d 100644 --- a/ui/src/graphql/client.ts +++ b/ui/src/graphql/client.ts @@ -1,5 +1,3 @@ -import { getAuthToken } from "@/utils/auth"; - /* eslint-disable import/no-unused-modules */ if (!process.env.NEXT_PUBLIC_GQL_ENDPOINT) { throw new Error("NEXT_PUBLIC_GQL_ENDPOINT is not set"); @@ -8,11 +6,9 @@ if (!process.env.NEXT_PUBLIC_GQL_ENDPOINT) { export const endpointUrl = process.env.NEXT_PUBLIC_GQL_ENDPOINT; export const fetchParams = () => { - const authToken = getAuthToken(); return { headers: { "Content-Type": "application/json; charset=utf-8", - ...(authToken && { Authorization: `Bearer ${authToken}` }), }, }; }; diff --git a/ui/src/pages/password-reset/[token].tsx b/ui/src/pages/password-reset/[token].tsx index bddbffa5..ce8ee15f 100644 --- a/ui/src/pages/password-reset/[token].tsx +++ b/ui/src/pages/password-reset/[token].tsx @@ -6,7 +6,6 @@ import ResetPasswordForm from "@/components/auth/ResetPasswordForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, useResetPasswordMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import { setCurrentUser } from "@/utils/auth"; const PasswordResetPage: NextPageWithLayout = () => { const router = useRouter(); @@ -20,7 +19,7 @@ const PasswordResetPage: NextPageWithLayout = () => { }, onSuccess: ({ resetPassword }) => { if (resetPassword) { - const { token, errors, user } = resetPassword; + const { errors, user } = resetPassword; if (errors && errors?.length > 0) { setErrors( @@ -29,7 +28,6 @@ const PasswordResetPage: NextPageWithLayout = () => { } if (user) { - setCurrentUser(user); router.push("/"); } } @@ -47,7 +45,7 @@ const PasswordResetPage: NextPageWithLayout = () => {
+ onSubmit={({ password, passwordConfirmation }) => submitResetPassword.mutate({ password, passwordConfirmation, diff --git a/ui/src/pages/password-reset/index.tsx b/ui/src/pages/password-reset/index.tsx index 2f18a6df..61149aa4 100644 --- a/ui/src/pages/password-reset/index.tsx +++ b/ui/src/pages/password-reset/index.tsx @@ -1,5 +1,4 @@ import Head from "next/head"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import ResetPasswordRequestForm from "@/components/auth/ResetPasswordRequestForm"; @@ -8,8 +7,6 @@ import { useRequestPasswordResetMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; const PasswordResetRequestPage: NextPageWithLayout = () => { - const router = useRouter(); - const [message, setMessage] = useState(); const submitPasswordResetRequest = useRequestPasswordResetMutation({ @@ -35,9 +32,7 @@ const PasswordResetRequestPage: NextPageWithLayout = () => {
- submitPasswordResetRequest.mutate({ - email, - }) + submitPasswordResetRequest.mutate(email) } message={message} /> diff --git a/ui/src/pages/register.tsx b/ui/src/pages/register.tsx index a46c9c33..50b221cc 100644 --- a/ui/src/pages/register.tsx +++ b/ui/src/pages/register.tsx @@ -6,11 +6,9 @@ import RegisterForm from "@/components/auth/RegisterForm"; import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { MutationError, - User, useRegisterWithPasswordMutation, } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import { setCurrentUser } from "@/utils/auth"; const RegisterPage: NextPageWithLayout = () => { const router = useRouter(); @@ -32,7 +30,6 @@ const RegisterPage: NextPageWithLayout = () => { } if (user) { - setCurrentUser(user as User); router.push("/"); } } @@ -50,13 +47,13 @@ const RegisterPage: NextPageWithLayout = () => {
+ }) => submitRegister.mutate({ firstName, lastName, diff --git a/ui/src/pages/sign-in.tsx b/ui/src/pages/sign-in.tsx index 43390342..0bb5e885 100644 --- a/ui/src/pages/sign-in.tsx +++ b/ui/src/pages/sign-in.tsx @@ -32,10 +32,9 @@ const SignInPage: NextPageWithLayout = () => { ); } - // if (user) { - // setCurrentUser(user); - // router.push("/"); - // } + if (user) { + router.push("/"); + } } }, onError: (error: Error) => { diff --git a/ui/src/utils/auth.ts b/ui/src/utils/auth.ts deleted file mode 100644 index 402f8e45..00000000 --- a/ui/src/utils/auth.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { User } from "@/graphql/generated"; - -export const setCurrentUser = (user: User) => { - localStorage.setItem("orcasound:user", JSON.stringify(user)); - return user; -}; - -export const getCurrentUser = () => { - const user = localStorage.getItem("orcasound:user"); - if (user) { - return JSON.parse(user) as User; - } -}; - -export const setAuthToken = (token: string) => { - localStorage.setItem("orcasound:auth_token", token); -}; -export const getAuthToken = () => { - return localStorage.getItem("orcasound:auth_token"); -}; From 82b709ca199dba439db0b9069e4a076cf840e9b5 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 6 Dec 2023 12:08:03 -0800 Subject: [PATCH 30/50] Underscore unused variables --- .../orcasite/notifications/resources/subscriber.ex | 11 ++++++----- .../orcasite/notifications/resources/subscription.ex | 11 ++++++----- server/lib/orcasite_web/router.ex | 4 +--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/server/lib/orcasite/notifications/resources/subscriber.ex b/server/lib/orcasite/notifications/resources/subscriber.ex index e3caa94b..2aed793c 100644 --- a/server/lib/orcasite/notifications/resources/subscriber.ex +++ b/server/lib/orcasite/notifications/resources/subscriber.ex @@ -123,13 +123,14 @@ defmodule Orcasite.Notifications.Subscriber do # 14 days (in minutes) token_lifetime 1_209_600 - sender fn subscriber, token, _opts -> - IO.inspect({subscriber, token}, - label: - "subscriber/token (server/lib/orcasite/notifications/resources/subscriber.ex:#{__ENV__.line})" - ) + sender fn _subscriber, _token, _opts -> + # IO.inspect({subscriber, token}, + # label: + # "subscriber/token (server/lib/orcasite/notifications/resources/subscriber.ex:#{__ENV__.line})" + # ) # Orcasite.Emails.deliver_magic_link(user, token) + :ok end end end diff --git a/server/lib/orcasite/notifications/resources/subscription.ex b/server/lib/orcasite/notifications/resources/subscription.ex index 93b648a3..2c8f419e 100644 --- a/server/lib/orcasite/notifications/resources/subscription.ex +++ b/server/lib/orcasite/notifications/resources/subscription.ex @@ -28,13 +28,14 @@ defmodule Orcasite.Notifications.Subscription do # 14 days (in minutes) token_lifetime 1_209_600 - sender fn subscription, token, _opts -> - IO.inspect({subscription, token}, - label: - "{subscription, token} (server/lib/orcasite/notifications/resources/subscription.ex:#{__ENV__.line})" - ) + sender fn _subscription, _token, _opts -> + # IO.inspect({subscription, token}, + # label: + # "{subscription, token} (server/lib/orcasite/notifications/resources/subscription.ex:#{__ENV__.line})" + # ) # Orcasite.Emails.deliver_magic_link(user, token) + :ok end end end diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index d0fe1ca7..2ad3a74e 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -171,8 +171,6 @@ defmodule OrcasiteWeb.Router do def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do if user = blueprint.execution.context[:current_user] do - IO.inspect(user, label: "Setting current user") - conn |> assign(:current_user, user) |> AshAuthentication.Plug.Helpers.store_in_session(user) @@ -186,7 +184,7 @@ defmodule OrcasiteWeb.Router do conn end - defp set_current_user_as_actor(%{assigns: %{current_user: actor}} = conn, _opts) do + defp set_current_user_as_actor(%{assigns: %{current_user: _actor}} = conn, _opts) do conn |> AshAuthentication.Plug.Helpers.set_actor(:user) end From 326e8782b5860595390e6c538b3b40625b4dde03 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 6 Dec 2023 12:51:35 -0800 Subject: [PATCH 31/50] Revert router fallback --- server/lib/orcasite_web/router.ex | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 2ad3a74e..a749144c 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -137,13 +137,17 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through(:nextjs) - ui_port = System.get_env("UI_PORT") || "3000" - # TODO: Figure out websocket proxying (ws:// protocl) for /_next/webpack-hmr - forward("/", ReverseProxyPlug, - upstream: "//localhost:#{ui_port}", - error_callback: &__MODULE__.log_reverse_proxy_error/1 - ) + if Mix.env() == :dev do + get("/*page", OrcasiteWeb.PageController, :index) + else + ui_port = System.get_env("UI_PORT") || "3000" + + forward("/", ReverseProxyPlug, + upstream: "http://localhost:#{ui_port}", + error_callback: &__MODULE__.log_reverse_proxy_error/1 + ) + end end def log_reverse_proxy_error(error) do @@ -171,6 +175,8 @@ defmodule OrcasiteWeb.Router do def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do if user = blueprint.execution.context[:current_user] do + IO.inspect(user, label: "Setting current user") + conn |> assign(:current_user, user) |> AshAuthentication.Plug.Helpers.store_in_session(user) From dc7406460f2eea8b3baf5d5591fcb6c214909b32 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 6 Dec 2023 13:23:05 -0800 Subject: [PATCH 32/50] Update authorization for auth actions --- server/lib/orcasite/accounts/token.ex | 14 +++++++------- server/lib/orcasite/accounts/user.ex | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/server/lib/orcasite/accounts/token.ex b/server/lib/orcasite/accounts/token.ex index 047b977a..afcd02f1 100644 --- a/server/lib/orcasite/accounts/token.ex +++ b/server/lib/orcasite/accounts/token.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Accounts.Token do use Ash.Resource, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication.TokenResource] + extensions: [AshAuthentication.TokenResource], + authorizers: [Ash.Policy.Authorizer] token do api Orcasite.Accounts @@ -12,10 +13,9 @@ defmodule Orcasite.Accounts.Token do repo Orcasite.Repo end - # If using policies, add the following bypass: - # policies do - # bypass AshAuthentication.Checks.AshAuthenticationInteraction do - # authorize_if always() - # end - # end + policies do + bypass AshAuthentication.Checks.AshAuthenticationInteraction do + authorize_if always() + end + end end diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index 58d23555..a58a2d09 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -67,6 +67,22 @@ defmodule Orcasite.Accounts.User do policy action(:current_user) do authorize_if always() end + + bypass action(:register_with_password) do + authorize_if always() + end + + bypass action(:sign_in_with_password) do + authorize_if always() + end + + bypass action(:request_password_reset_with_password) do + authorize_if always() + end + + bypass action(:password_reset_with_password) do + authorize_if always() + end end actions do From 9b45f360b54cc3e75f28bd7c1f990ba955807427 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 6 Dec 2023 14:10:05 -0800 Subject: [PATCH 33/50] Clear session on signOut graphql request --- .../orcasite_web/graphql/types/accounts.ex | 16 ++++++++ server/lib/orcasite_web/router.ex | 26 ++++++++---- ui/src/graphql/generated/index.ts | 41 +++++++++++++++++++ ui/src/graphql/mutations/signOut.graphql | 3 ++ .../{sign-out/index.tsx => sign-out.tsx} | 13 +++--- 5 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 ui/src/graphql/mutations/signOut.graphql rename ui/src/pages/{sign-out/index.tsx => sign-out.tsx} (92%) diff --git a/server/lib/orcasite_web/graphql/types/accounts.ex b/server/lib/orcasite_web/graphql/types/accounts.ex index b20aa2c1..d001893f 100644 --- a/server/lib/orcasite_web/graphql/types/accounts.ex +++ b/server/lib/orcasite_web/graphql/types/accounts.ex @@ -87,6 +87,14 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do middleware(&set_current_user/2) end + + field :sign_out, type: :boolean do + resolve(fn _, _, _ -> + {:ok, true} + end) + + middleware(&clear_session/2) + end end defp set_current_user(resolution, _) do @@ -96,4 +104,12 @@ defmodule OrcasiteWeb.Graphql.Types.Accounts do end) end end + + defp clear_session(resolution, _) do + Map.update!(resolution, :context, fn ctx -> + ctx + |> Map.put(:current_user, nil) + |> Map.put(:clear_session, true) + end) + end end diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index a749144c..1a7dd7dd 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -88,7 +88,8 @@ defmodule OrcasiteWeb.Router do forward("/", Absinthe.Plug.GraphiQL, schema: OrcasiteWeb.Schema, interface: :playground, - json_codec: Jason + json_codec: Jason, + before_send: {__MODULE__, :absinthe_before_send} ) end @@ -174,15 +175,22 @@ defmodule OrcasiteWeb.Router do end def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do - if user = blueprint.execution.context[:current_user] do - IO.inspect(user, label: "Setting current user") + blueprint.execution.context + |> case do + %{current_user: user} when not is_nil(user) -> + conn + |> assign(:current_user, user) + |> AshAuthentication.Plug.Helpers.store_in_session(user) + |> AshAuthentication.Plug.Helpers.set_actor(:user) - conn - |> assign(:current_user, user) - |> AshAuthentication.Plug.Helpers.store_in_session(user) - |> AshAuthentication.Plug.Helpers.set_actor(:user) - else - conn + %{clear_session: true} -> + conn + |> assign(:current_user, nil) + |> AshAuthentication.Plug.Helpers.set_actor(:user) + |> clear_session() + + _ -> + conn end end diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index ff50b477..d8e9b148 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -456,6 +456,7 @@ export type RootMutationType = { requestPasswordReset?: Maybe; resetPassword?: Maybe; signInWithPassword?: Maybe; + signOut?: Maybe; submitDetection?: Maybe; }; @@ -736,6 +737,13 @@ export type SignInWithPasswordMutation = { } | null; }; +export type SignOutMutationVariables = Exact<{ [key: string]: never }>; + +export type SignOutMutation = { + __typename?: "RootMutationType"; + signOut?: boolean | null; +}; + export type SubmitDetectionMutationVariables = Exact<{ feedId: Scalars["String"]["input"]; playlistTimestamp: Scalars["Int"]["input"]; @@ -1088,6 +1096,39 @@ useSignInWithPasswordMutation.fetcher = ( variables, options, ); +export const SignOutDocument = ` + mutation signOut { + signOut +} + `; +export const useSignOutMutation = ( + options?: UseMutationOptions< + SignOutMutation, + TError, + SignOutMutationVariables, + TContext + >, +) => + useMutation( + ["signOut"], + (variables?: SignOutMutationVariables) => + fetcher( + SignOutDocument, + variables, + )(), + options, + ); +useSignOutMutation.getKey = () => ["signOut"]; + +useSignOutMutation.fetcher = ( + variables?: SignOutMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + SignOutDocument, + variables, + options, + ); export const SubmitDetectionDocument = ` mutation submitDetection($feedId: String!, $playlistTimestamp: Int!, $playerOffset: Decimal!, $description: String!, $listenerCount: Int, $category: DetectionCategory!) { submitDetection( diff --git a/ui/src/graphql/mutations/signOut.graphql b/ui/src/graphql/mutations/signOut.graphql new file mode 100644 index 00000000..802a3016 --- /dev/null +++ b/ui/src/graphql/mutations/signOut.graphql @@ -0,0 +1,3 @@ +mutation signOut { + signOut +} \ No newline at end of file diff --git a/ui/src/pages/sign-out/index.tsx b/ui/src/pages/sign-out.tsx similarity index 92% rename from ui/src/pages/sign-out/index.tsx rename to ui/src/pages/sign-out.tsx index ee0f0bd4..eb5d0bf0 100644 --- a/ui/src/pages/sign-out/index.tsx +++ b/ui/src/pages/sign-out.tsx @@ -5,18 +5,21 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import Header from "@/components/Header"; +import { useSignOutMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import logo from "@/public/wordmark/wordmark-teal.svg"; -import { clearAuthToken, clearCurrentUser } from "@/utils/auth"; const SignInPage: NextPageWithLayout = () => { const router = useRouter(); + const signOut = useSignOutMutation({ + onSuccess: () => { + router.push("/"); + }, + }); useEffect(() => { - clearCurrentUser(); - clearAuthToken(); - router.push("/"); - }, [router]); + signOut.mutate({}); + }, []); return (
From 8b848b8289a66d0f2eef20d243858cb12813cbf3 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 8 Dec 2023 11:49:03 -0800 Subject: [PATCH 34/50] Update dependencies --- server/mix.exs | 6 +++--- server/mix.lock | 32 ++++++++++++++++---------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/server/mix.exs b/server/mix.exs index 28e2b081..c6ea6a90 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -79,14 +79,14 @@ defmodule Orcasite.Mixfile do {:heroicons, "~> 0.5"}, {:oban, "~> 2.14"}, {:gen_smtp, "~> 1.0"}, - {:ash_authentication, "~> 3.11.6"}, - {:ash_authentication_phoenix, "~> 1.8.3"}, + {:ash_authentication, "~> 3.12.0"}, + {:ash_authentication_phoenix, "~> 1.9.0"}, {:syn, "~> 3.3"}, {:mjml, "~> 1.5.0"}, {:zappa, github: "skanderm/zappa", branch: "master"}, {:ash_uuid, "~> 0.4"}, {:ash_graphql, "~> 0.26.6"}, - {:ash_json_api, "~> 0.33.0"}, + {:ash_json_api, "~> 0.34.0"}, {:open_api_spex, "~> 3.16"}, {:redoc_ui_plug, "~> 0.2.1"}, {:phoenix_pubsub_redis, "~> 3.0.1"}, diff --git a/server/mix.lock b/server/mix.lock index 244c3591..c6396170 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -1,16 +1,16 @@ %{ "absinthe": {:hex, :absinthe, "1.7.6", "0b897365f98d068cfcb4533c0200a8e58825a4aeeae6ec33633ebed6de11773b", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7626951ca5eec627da960615b51009f3a774765406ff02722b1d818f17e5778"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, - "ash": {:hex, :ash, "2.17.3", "b87448baa52360d3a8934526bb9e6c29cbbc0fa0047884a8d5616fda7ee263a4", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.50 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.6", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "03d310f44240d8c47e7b405afcc8d87b290dac702698d1b470374ba54ae45c49"}, + "ash": {:hex, :ash, "2.17.9", "194d1bd5facdc0059ab315dc023ffafd871b1cfbe2febdac7f7858f4323be1b1", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.50 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.6", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "855472c7cce9cd96987bf3905d2d9fee0a1f080bafbb0eb2697fc80fb21bdbe4"}, "ash_admin": {:git, "https://github.com/skanderm/ash_admin.git", "b0e018c7aaeecc3d74d48f354c76c9b3f434b8e7", [branch: "main"]}, - "ash_authentication": {:hex, :ash_authentication, "3.11.16", "018917a985d44509d5a571d000d40aa499806baba473f6292c73bce7b3c7dc29", [:mix], [{:ash, ">= 2.5.11 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:assent, "~> 0.2", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.16.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 1.1.39 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c15d53519df6fa2c9896d148f4ffd77d836445d256bb92292f865bd6932c6432"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "1.8.7", "b6349049c08819b9d0eea5996b29863b37d5c37dcd2179e4c123e6f13dbd4c59", [:mix], [{:ash, "~> 2.2", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 3.11.9 and < 4.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 1.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "dd767e84d4514cc313712667529964ae89585a556292700558601086f7da45c3"}, - "ash_graphql": {:hex, :ash_graphql, "0.26.6", "38a2a85cc58d31cce576e601e6d6932ae329a80f11b4184d083d391b7523ab03", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 2.14.17 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e16e5c28cc050a8ef9c9fc5e50c3d14b7b670d9c42ed6e4449c6844ee9325ed0"}, - "ash_json_api": {:hex, :ash_json_api, "0.33.1", "697444ff088235eb742c8661cc127c18cdebadb55cb944fc38bdef7b87df0a7e", [:mix], [{:ash, ">= 2.9.24 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2767041ae3a763bd05c89ca430ec3d649a7aae161c503314abcf72f381bb2679"}, + "ash_authentication": {:hex, :ash_authentication, "3.12.0", "bb39cc279b0bd8c4a4640ac38a7901158e6735b524f5daf783d4e3e423ee2564", [:mix], [{:ash, ">= 2.5.11 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:assent, ">= 0.2.8 and < 1.0.0-0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.16.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 1.1.39 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a7547bbabf70f5b7b27cfcfe2520573373e960e9ca5b81735e1ca68dc3991ded"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "1.9.0", "b9d1ba7a81db6ab04fe7c9d612329a702d20ed5b6bb3d3f123763abd1807be72", [:mix], [{:ash, "~> 2.2", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 3.11.9 and < 4.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 1.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "b19244f1b48c5e3374244e71b682b8fab84e216f9c226a046fafb89eaff3112b"}, + "ash_graphql": {:hex, :ash_graphql, "0.26.8", "ca0af0d267d3cb8e7d1cf006b132f7120f7635b54187c7e150c373a652bff09f", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, "~> 2.17", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dfb0a6b9840715b77fce4b95e0e820133706de76c6b1f9bafc16fa9889e7e739"}, + "ash_json_api": {:hex, :ash_json_api, "0.34.0", "f11b21c322cead92d0a886c2f9640a35c5866e5024c4744ad1869996aeb3b123", [:mix], [{:ash, ">= 2.9.24 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "192d805447e2ed506751a2ae6f58f564741f68a9e8cba1a71a2f6f3928e182f1"}, "ash_phoenix": {:hex, :ash_phoenix, "1.2.23", "9d98bb2c1f9762e27411a5a021b43d5fb5ab716f195346d2b5edc422d789ed23", [:mix], [{:ash, ">= 2.14.1 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "64a09ad8969e83a36da0975f66e0d3acfb66754e0d091da7bfe1c94b70a5b6ff"}, - "ash_postgres": {:hex, :ash_postgres, "1.3.62", "e8b661a0a88a771f7139dcd7c9632cc140f9b05c278cc0ee297638cb47782c1f", [:mix], [{:ash, ">= 2.17.3 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "99e5702f72ec54d65a2571bebb89e3867ca7220eeca7c89aa7935dcf6e0cc0a7"}, + "ash_postgres": {:hex, :ash_postgres, "1.3.64", "7d7b66c482ffc934a93d9872649d22da0b832cbcb9f3a14b858a3e830100302a", [:mix], [{:ash, ">= 2.17.7 and < 3.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "22a40de58746ceae628b89e48317ab8bd4cf6b9cdf88c1e3a006773c4c606cd0"}, "ash_uuid": {:hex, :ash_uuid, "0.6.0", "e78a2bd5ad276f9e01865891dea535105b12de3a925c6a14684ba31fd4559361", [:mix], [{:ash, "~> 2.13", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 1.3.41", [hex: :ash_postgres, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "b19499ca47361db2c9bcd98dea338817f4ddc13de4a8b167249b4db074377d0e"}, - "assent": {:hex, :assent, "0.2.8", "72abd81d182e2a2902c74d926eb1b0c186756299f4393a6844ea4757407731e6", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "9f977d0358402a6c8807f10faa9876f997186447e1b353d191248007eb45acfe"}, + "assent": {:hex, :assent, "0.2.9", "e3cdbc8f2e4f8d02c4c490ef8c2148bb1bc0d81aa0648f09addc5918d9a1cd5a", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "5f9562bda90bef7bd3f1b9a348520a5631b86c85145346bb7edb8a7ebbad8e86"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, @@ -25,19 +25,19 @@ "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"}, - "ecto": {:hex, :ecto, "3.11.0", "ff8614b4e70a774f9d39af809c426def80852048440e8785d93a6e91f48fec00", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7769dad267ef967310d6e988e92d772659b11b09a0c015f101ce0fff81ce1f81"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.0", "c787b24b224942b69c9ff7ab9107f258ecdc68326be04815c6cce2941b6fad1c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "77aa3677169f55c2714dda7352d563002d180eb33c0dc29cd36d39c0a1a971f5"}, + "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, - "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, + "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "geo": {:hex, :geo, "3.6.0", "00c9c6338579f67e91cd5950af4ae2eb25cdce0c3398718c232539f61625d0bd", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1dbdebf617183b54bc3c8ad7a36531a9a76ada8ca93f75f573b0ae94006168da"}, "geo_postgis": {:hex, :geo_postgis, "3.5.0", "e3675b6276b8c2166dc20a6fa9d992eb73c665de2b09b666d09c7824dc8a8300", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:geo, "~> 3.5", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0bebc5b00f8b11835066bd6213fbeeec03704b4a1c206920b81c1ec2201d185f"}, - "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, + "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "guardian": {:hex, :guardian, "2.3.1", "2b2d78dc399a7df182d739ddc0e566d88723299bfac20be36255e2d052fd215d", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bbe241f9ca1b09fad916ad42d6049d2600bbc688aba5b3c4a6c82592a54274c3"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"}, @@ -62,7 +62,7 @@ "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, - "oban": {:hex, :oban, "2.16.3", "33ebe7da637cce4da5438c1636bc25448a8628994a0c064ac6078bbe6dc97bd6", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d8a7fb62f63cf2f2080c78954425f5fd8916ef57196b7f79b5bc657abb2ac5f"}, + "oban": {:hex, :oban, "2.17.0", "991ca80f3db4a7afbe854822300858f66a95f3ac841d643f3e9a57a5f5f76eb7", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab9565ae8123f6bf726b3dbd474873f0dc1a4ba511a9d55de37fae202361359f"}, "open_api_spex": {:hex, :open_api_spex, "3.18.0", "f9952b6bc8a1bf14168f3754981b7c8d72d015112bfedf2588471dd602e1e715", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "37849887ab67efab052376401fac28c0974b273ffaecd98f4532455ca0886464"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, @@ -81,11 +81,11 @@ "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, + "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "redix": {:hex, :redix, "1.3.0", "f4121163ff9d73bf72157539ff23b13e38422284520bb58c05e014b19d6f0577", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "60d483d320c77329c8cbd3df73007e51b23f3fae75b7693bc31120d83ab26131"}, "redoc_ui_plug": {:hex, :redoc_ui_plug, "0.2.1", "5e9760c17ed450fc9df671d5fbc70a6f06179c41d9d04ae3c33f16baca3a5b19", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7be01db31f210887e9fc18f8fbccc7788de32c482b204623556e415ed1fe714b"}, - "reverse_proxy_plug": {:hex, :reverse_proxy_plug, "2.2.0", "3c54f9c8e7c0a9559bc2f215990c03e68074d43f0facfd396930525e2b6a8a19", [:mix], [{:cowboy, "~> 2.4", [hex: :cowboy, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.2 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "391a6307188fef99deed033e2d636e7d42d565576ca4cb1c0b2322e5939b7997"}, + "reverse_proxy_plug": {:hex, :reverse_proxy_plug, "2.3.0", "6830ae83b383576899826d09ac00328e559455de650c9f047a413f1b64de450d", [:mix], [{:cowboy, "~> 2.4", [hex: :cowboy, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.2 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "eb0007fb8fc6a4a1627c62270e2713e99bea31883daac77c85d60dd10dcdfdc3"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.6.3", "f838d94bc35e1844973ee7266127b156fdc962e9e8b7ff666c8fb4fed7964d23", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "e18ecca3669a7454b3a2be75ae6c3ef01d550bc9a8cf5fbddcfff843b881d7c6"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, @@ -94,9 +94,9 @@ "spark": {:hex, :spark, "1.1.51", "8458de5abbb89d18dd5c9235dd39e3757076eba84a5078d1cdc2c1e23c39aa95", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "ed8410aa8db08867b8fff3d65e54deeb7f6f6cf2b8698fc405a386c1c7a9e4f0"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"}, - "swoosh": {:hex, :swoosh, "1.14.1", "d8813699ba410509008dd3dfdb2df057e3fce367d45d5e6d76b146a7c9d559cd", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87da72260b4351678f96aec61db5c2acc8a88cda2cf2c4f534eb4c9c461350c7"}, + "swoosh": {:hex, :swoosh, "1.14.2", "cf686f92ad3b21e6651b20c50eeb1781f581dc7097ef6251b4d322a9f1d19339", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01d8fae72930a0b5c1bb9725df0408602ed8c5c3d59dc6e7a39c57b723cd1065"}, "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, - "tails": {:hex, :tails, "0.1.7", "d77a89c2faea02237d78ea81824c1362dbc3cfa4e2a203be7808617ae47bb5eb", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d5ae73c55d181ab1353f2c49aea2ef540edfa6ec6be65a89e8a392ba15a94b21"}, + "tails": {:hex, :tails, "0.1.8", "ca728ace5ab846a496ca72573804da0d90ca1c23aa51d501b3498dc1abdf6be1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f4e13037937cd447900b2fef406e1acf456442101e53702b7cdd79d81821e5e5"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, From c09acd2a8f2bb13edb44d7fa78701b558e785162 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 8 Dec 2023 11:55:47 -0800 Subject: [PATCH 35/50] Add moderator flag to users --- server/lib/orcasite/accounts/user.ex | 1 + .../20231208195135_add_moderator_to_users.exs | 21 ++++ .../repo/users/20231208195135.json | 118 ++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 server/priv/repo/migrations/20231208195135_add_moderator_to_users.exs create mode 100644 server/priv/resource_snapshots/repo/users/20231208195135.json diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index a58a2d09..1b2056a7 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -20,6 +20,7 @@ defmodule Orcasite.Accounts.User do attribute :first_name, :string attribute :last_name, :string attribute :admin, :boolean + attribute :moderator, :boolean create_timestamp :inserted_at update_timestamp :updated_at diff --git a/server/priv/repo/migrations/20231208195135_add_moderator_to_users.exs b/server/priv/repo/migrations/20231208195135_add_moderator_to_users.exs new file mode 100644 index 00000000..b9b791d5 --- /dev/null +++ b/server/priv/repo/migrations/20231208195135_add_moderator_to_users.exs @@ -0,0 +1,21 @@ +defmodule Orcasite.Repo.Migrations.AddModeratorToUsers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:users) do + add :moderator, :boolean + end + end + + def down do + alter table(:users) do + remove :moderator + end + end +end \ No newline at end of file diff --git a/server/priv/resource_snapshots/repo/users/20231208195135.json b/server/priv/resource_snapshots/repo/users/20231208195135.json new file mode 100644 index 00000000..128d54fe --- /dev/null +++ b/server/priv/resource_snapshots/repo/users/20231208195135.json @@ -0,0 +1,118 @@ +{ + "attributes": [ + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "citext", + "source": "email", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "hashed_password", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "first_name", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "last_name", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "boolean", + "source": "admin", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "boolean", + "source": "moderator", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + } + ], + "table": "users", + "hash": "D3A73E35764C73DD6920ADF9E40B45DDDFAE9D669111079CB09C01EF35882611", + "repo": "Elixir.Orcasite.Repo", + "identities": [ + { + "name": "unique_email", + "keys": [ + "email" + ], + "index_name": "users_unique_email_index", + "base_filter": null + } + ], + "schema": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file From 805693efc761986bf76b6e935cd53fd9fd86b08d Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 11 Dec 2023 16:15:43 -0800 Subject: [PATCH 36/50] Add visible flags to candidates, detections. Add policies and mutations for updating visible flags. --- server/lib/orcasite/radio/candidate.ex | 20 ++ server/lib/orcasite/radio/detection.ex | 17 ++ server/lib/orcasite_web/router.ex | 8 +- server/priv/repo/seeds.exs | 1 + ui/src/components/DetectionsTable.tsx | 25 +- ui/src/graphql/generated/index.ts | 217 +++++++++++++++++- .../mutations/setCandidateVisible.graphql | 8 + .../mutations/setDetectionVisible.graphql | 8 + ui/src/graphql/queries/getCandidate.graphql | 1 + ui/src/graphql/queries/getCurrentUser.graphql | 1 + ui/src/graphql/queries/listCandidates.graphql | 2 + ui/src/pages/reports/index.tsx | 8 - 12 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 ui/src/graphql/mutations/setCandidateVisible.graphql create mode 100644 ui/src/graphql/mutations/setDetectionVisible.graphql diff --git a/server/lib/orcasite/radio/candidate.ex b/server/lib/orcasite/radio/candidate.ex index 42d09940..53184f9b 100644 --- a/server/lib/orcasite/radio/candidate.ex +++ b/server/lib/orcasite/radio/candidate.ex @@ -25,6 +25,7 @@ defmodule Orcasite.Radio.Candidate do attribute :detection_count, :integer attribute :min_time, :utc_datetime_usec, allow_nil?: false attribute :max_time, :utc_datetime_usec, allow_nil?: false + attribute :visible, :boolean, default: true create_timestamp :inserted_at update_timestamp :updated_at @@ -52,6 +53,14 @@ defmodule Orcasite.Radio.Candidate do policy action_type(:create) do authorize_if always() end + + policy changing_attributes([:visible]) do + authorize_if actor_attribute_equals(:moderator, true) + end + + policy expr(is_nil(visible) or not visible) do + authorize_if actor_attribute_equals(:moderator, true) + end end actions do @@ -115,6 +124,13 @@ defmodule Orcasite.Radio.Candidate do ) end end + + update :set_visible do + accept [:visible] + argument :visible, :boolean, default: true + + change set_attribute(:visible, arg(:visible)) + end end admin do @@ -128,5 +144,9 @@ defmodule Orcasite.Radio.Candidate do get :candidate, :read list :candidates, :index end + + mutations do + update :set_candidate_visible, :set_visible + end end end diff --git a/server/lib/orcasite/radio/detection.ex b/server/lib/orcasite/radio/detection.ex index d71967ea..736ed16e 100644 --- a/server/lib/orcasite/radio/detection.ex +++ b/server/lib/orcasite/radio/detection.ex @@ -30,6 +30,7 @@ defmodule Orcasite.Radio.Detection do attribute :listener_count, :integer attribute :timestamp, :utc_datetime_usec, allow_nil?: false attribute :description, :string + attribute :visible, :boolean, default: true attribute :category, :atom do # TODO: Make non-null after we migrate @@ -62,6 +63,14 @@ defmodule Orcasite.Radio.Detection do policy action_type(:create) do authorize_if always() end + + policy changing_attributes([:visible]) do + authorize_if actor_attribute_equals(:moderator, true) + end + + policy expr(is_nil(visible) or not visible) do + authorize_if actor_attribute_equals(:moderator, true) + end end actions do @@ -106,6 +115,13 @@ defmodule Orcasite.Radio.Detection do change manage_relationship(:candidate, type: :append) end + update :set_visible do + accept [:visible] + argument :visible, :boolean, default: true + + change set_attribute(:visible, arg(:visible)) + end + create :create do primary? true argument :candidate, :map @@ -240,6 +256,7 @@ defmodule Orcasite.Radio.Detection do mutations do create :submit_detection, :submit_detection + update :set_detection_visible, :set_visible end end diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index a749144c..0990d7d9 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -138,16 +138,16 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through(:nextjs) - if Mix.env() == :dev do - get("/*page", OrcasiteWeb.PageController, :index) - else + # if Mix.env() == :dev do + # get("/*page", OrcasiteWeb.PageController, :index) + # else ui_port = System.get_env("UI_PORT") || "3000" forward("/", ReverseProxyPlug, upstream: "http://localhost:#{ui_port}", error_callback: &__MODULE__.log_reverse_proxy_error/1 ) - end + # end end def log_reverse_proxy_error(error) do diff --git a/server/priv/repo/seeds.exs b/server/priv/repo/seeds.exs index 85ee285e..816f3fde 100644 --- a/server/priv/repo/seeds.exs +++ b/server/priv/repo/seeds.exs @@ -79,6 +79,7 @@ Orcasite.Accounts.User password_confirmation: "password" }) |> Ash.Changeset.force_change_attribute(:admin, true) +|> Ash.Changeset.force_change_attribute(:moderator, true) |> Orcasite.Accounts.create() [ diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 1e0064ed..149b63dd 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -1,5 +1,6 @@ import { Box, + Button, Table, TableBody, TableCell, @@ -7,7 +8,12 @@ import { TableRow, } from "@mui/material"; -import { Candidate, Detection, Feed } from "@/graphql/generated"; +import { + Candidate, + Detection, + Feed, + useGetCurrentUserQuery, +} from "@/graphql/generated"; import { analytics } from "@/utils/analytics"; import { formatTimestamp } from "@/utils/time"; @@ -16,7 +22,7 @@ import { DetectionsPlayer } from "./Player/DetectionsPlayer"; export default function DetectionsTable({ detections, feed, - candidate + candidate, }: { detections: Detection[]; feed: Pick; @@ -28,6 +34,9 @@ export default function DetectionsTable({ const startOffset = Math.max(0, minOffset - offsetPadding); const endOffset = maxOffset + offsetPadding; + const { currentUser } = useGetCurrentUserQuery().data ?? {}; + console.log("user", currentUser); + return ( Category Description Timestamp + {currentUser?.moderator && Actions} @@ -74,6 +84,17 @@ export default function DetectionsTable({ {formatTimestamp(detection.timestamp)} + {currentUser?.moderator && ( + + + + )} ))} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index ff50b477..837b522e 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -46,6 +46,7 @@ export type Candidate = { maxTime: Scalars["DateTime"]["output"]; minTime: Scalars["DateTime"]["output"]; uuid?: Maybe; + visible?: Maybe; }; export type CandidateDetectionsArgs = { @@ -80,6 +81,7 @@ export type CandidateFilterInput = { minTime?: InputMaybe; not?: InputMaybe>; or?: InputMaybe>; + visible?: InputMaybe; }; export type CandidateFilterMaxTime = { @@ -104,11 +106,23 @@ export type CandidateFilterMinTime = { notEq?: InputMaybe; }; +export type CandidateFilterVisible = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + export type CandidateSortField = | "DETECTION_COUNT" | "ID" | "MAX_TIME" - | "MIN_TIME"; + | "MIN_TIME" + | "VISIBLE"; export type CandidateSortInput = { field: CandidateSortField; @@ -128,6 +142,7 @@ export type Detection = { sourceIp?: Maybe; timestamp: Scalars["DateTime"]["output"]; uuid?: Maybe; + visible?: Maybe; }; export type DetectionCategory = "OTHER" | "VESSEL" | "WHALE"; @@ -172,6 +187,7 @@ export type DetectionFilterInput = { playlistTimestamp?: InputMaybe; sourceIp?: InputMaybe; timestamp?: InputMaybe; + visible?: InputMaybe; }; export type DetectionFilterListenerCount = { @@ -229,6 +245,17 @@ export type DetectionFilterTimestamp = { notEq?: InputMaybe; }; +export type DetectionFilterVisible = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + export type DetectionSortField = | "CATEGORY" | "DESCRIPTION" @@ -237,7 +264,8 @@ export type DetectionSortField = | "PLAYER_OFFSET" | "PLAYLIST_TIMESTAMP" | "SOURCE_IP" - | "TIMESTAMP"; + | "TIMESTAMP" + | "VISIBLE"; export type DetectionSortInput = { field: DetectionSortField; @@ -455,6 +483,8 @@ export type RootMutationType = { registerWithPassword?: Maybe; requestPasswordReset?: Maybe; resetPassword?: Maybe; + setCandidateVisible?: Maybe; + setDetectionVisible?: Maybe; signInWithPassword?: Maybe; submitDetection?: Maybe; }; @@ -471,6 +501,16 @@ export type RootMutationTypeResetPasswordArgs = { input: PasswordResetInput; }; +export type RootMutationTypeSetCandidateVisibleArgs = { + id?: InputMaybe; + input?: InputMaybe; +}; + +export type RootMutationTypeSetDetectionVisibleArgs = { + id?: InputMaybe; + input?: InputMaybe; +}; + export type RootMutationTypeSignInWithPasswordArgs = { input: SignInWithPasswordInput; }; @@ -526,6 +566,32 @@ export type RootQueryTypeFeedsArgs = { sort?: InputMaybe>>; }; +export type SetCandidateVisibleInput = { + visible?: InputMaybe; +}; + +/** The result of the :set_candidate_visible mutation */ +export type SetCandidateVisibleResult = { + __typename?: "SetCandidateVisibleResult"; + /** Any errors generated, if the mutation failed */ + errors?: Maybe>>; + /** The successful result of the mutation */ + result?: Maybe; +}; + +export type SetDetectionVisibleInput = { + visible?: InputMaybe; +}; + +/** The result of the :set_detection_visible mutation */ +export type SetDetectionVisibleResult = { + __typename?: "SetDetectionVisibleResult"; + /** Any errors generated, if the mutation failed */ + errors?: Maybe>>; + /** The successful result of the mutation */ + result?: Maybe; +}; + export type SignInWithPasswordInput = { email: Scalars["String"]["input"]; password: Scalars["String"]["input"]; @@ -571,6 +637,7 @@ export type User = { firstName?: Maybe; id: Scalars["ID"]["output"]; lastName?: Maybe; + moderator?: Maybe; }; export type UserFilterAdmin = { @@ -624,6 +691,7 @@ export type UserFilterInput = { firstName?: InputMaybe; id?: InputMaybe; lastName?: InputMaybe; + moderator?: InputMaybe; not?: InputMaybe>; or?: InputMaybe>; }; @@ -639,6 +707,17 @@ export type UserFilterLastName = { notEq?: InputMaybe; }; +export type UserFilterModerator = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + export type RegisterWithPasswordMutationVariables = Exact<{ firstName?: InputMaybe; lastName?: InputMaybe; @@ -708,6 +787,40 @@ export type ResetPasswordMutation = { } | null; }; +export type SetCandidateVisibleMutationVariables = Exact<{ + id: Scalars["ID"]["input"]; + visible: Scalars["Boolean"]["input"]; +}>; + +export type SetCandidateVisibleMutation = { + __typename?: "RootMutationType"; + setCandidateVisible?: { + __typename?: "SetCandidateVisibleResult"; + result?: { + __typename?: "Candidate"; + id: string; + visible?: boolean | null; + } | null; + } | null; +}; + +export type SetDetectionVisibleMutationVariables = Exact<{ + id: Scalars["ID"]["input"]; + visible: Scalars["Boolean"]["input"]; +}>; + +export type SetDetectionVisibleMutation = { + __typename?: "RootMutationType"; + setDetectionVisible?: { + __typename?: "SetDetectionVisibleResult"; + result?: { + __typename?: "Detection"; + id: string; + visible?: boolean | null; + } | null; + } | null; +}; + export type SignInWithPasswordMutationVariables = Exact<{ email: Scalars["String"]["input"]; password: Scalars["String"]["input"]; @@ -781,6 +894,7 @@ export type CandidateQuery = { playlistTimestamp: number; playerOffset: number; timestamp: Date; + visible?: boolean | null; }>; } | null; }; @@ -796,6 +910,7 @@ export type GetCurrentUserQuery = { lastName?: string | null; email: string; admin?: boolean | null; + moderator?: boolean | null; } | null; }; @@ -840,6 +955,7 @@ export type CandidatesQuery = { minTime: Date; maxTime: Date; detectionCount?: number | null; + visible?: boolean | null; feed: { __typename?: "Feed"; id: string; @@ -856,6 +972,7 @@ export type CandidatesQuery = { playlistTimestamp: number; playerOffset: number; timestamp: Date; + visible?: boolean | null; }>; }> | null; } | null; @@ -1032,6 +1149,98 @@ useResetPasswordMutation.fetcher = ( variables, options, ); +export const SetCandidateVisibleDocument = ` + mutation setCandidateVisible($id: ID!, $visible: Boolean!) { + setCandidateVisible(id: $id, input: {visible: $visible}) { + result { + id + visible + } + } +} + `; +export const useSetCandidateVisibleMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + SetCandidateVisibleMutation, + TError, + SetCandidateVisibleMutationVariables, + TContext + >, +) => + useMutation< + SetCandidateVisibleMutation, + TError, + SetCandidateVisibleMutationVariables, + TContext + >( + ["setCandidateVisible"], + (variables?: SetCandidateVisibleMutationVariables) => + fetcher< + SetCandidateVisibleMutation, + SetCandidateVisibleMutationVariables + >(SetCandidateVisibleDocument, variables)(), + options, + ); +useSetCandidateVisibleMutation.getKey = () => ["setCandidateVisible"]; + +useSetCandidateVisibleMutation.fetcher = ( + variables: SetCandidateVisibleMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + SetCandidateVisibleDocument, + variables, + options, + ); +export const SetDetectionVisibleDocument = ` + mutation setDetectionVisible($id: ID!, $visible: Boolean!) { + setDetectionVisible(id: $id, input: {visible: $visible}) { + result { + id + visible + } + } +} + `; +export const useSetDetectionVisibleMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + SetDetectionVisibleMutation, + TError, + SetDetectionVisibleMutationVariables, + TContext + >, +) => + useMutation< + SetDetectionVisibleMutation, + TError, + SetDetectionVisibleMutationVariables, + TContext + >( + ["setDetectionVisible"], + (variables?: SetDetectionVisibleMutationVariables) => + fetcher< + SetDetectionVisibleMutation, + SetDetectionVisibleMutationVariables + >(SetDetectionVisibleDocument, variables)(), + options, + ); +useSetDetectionVisibleMutation.getKey = () => ["setDetectionVisible"]; + +useSetDetectionVisibleMutation.fetcher = ( + variables: SetDetectionVisibleMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + SetDetectionVisibleDocument, + variables, + options, + ); export const SignInWithPasswordDocument = ` mutation signInWithPassword($email: String!, $password: String!) { signInWithPassword(input: {email: $email, password: $password}) { @@ -1156,6 +1365,7 @@ export const CandidateDocument = ` playlistTimestamp playerOffset timestamp + visible } } } @@ -1195,6 +1405,7 @@ export const GetCurrentUserDocument = ` lastName email admin + moderator } } `; @@ -1272,6 +1483,7 @@ export const CandidatesDocument = ` minTime maxTime detectionCount + visible feed { id slug @@ -1286,6 +1498,7 @@ export const CandidatesDocument = ` playlistTimestamp playerOffset timestamp + visible } } } diff --git a/ui/src/graphql/mutations/setCandidateVisible.graphql b/ui/src/graphql/mutations/setCandidateVisible.graphql new file mode 100644 index 00000000..96a12f54 --- /dev/null +++ b/ui/src/graphql/mutations/setCandidateVisible.graphql @@ -0,0 +1,8 @@ +mutation setCandidateVisible($id: ID!, $visible: Boolean!) { + setCandidateVisible(id: $id, input: { visible: $visible }) { + result { + id + visible + } + } +} diff --git a/ui/src/graphql/mutations/setDetectionVisible.graphql b/ui/src/graphql/mutations/setDetectionVisible.graphql new file mode 100644 index 00000000..6b96ad08 --- /dev/null +++ b/ui/src/graphql/mutations/setDetectionVisible.graphql @@ -0,0 +1,8 @@ +mutation setDetectionVisible($id: ID!, $visible: Boolean!) { + setDetectionVisible(id: $id, input: { visible: $visible }) { + result { + id + visible + } + } +} diff --git a/ui/src/graphql/queries/getCandidate.graphql b/ui/src/graphql/queries/getCandidate.graphql index 7d89caf8..841c3694 100644 --- a/ui/src/graphql/queries/getCandidate.graphql +++ b/ui/src/graphql/queries/getCandidate.graphql @@ -18,6 +18,7 @@ query candidate($id: ID!) { playlistTimestamp playerOffset timestamp + visible } } } diff --git a/ui/src/graphql/queries/getCurrentUser.graphql b/ui/src/graphql/queries/getCurrentUser.graphql index 88ef8e22..8a5ddbad 100644 --- a/ui/src/graphql/queries/getCurrentUser.graphql +++ b/ui/src/graphql/queries/getCurrentUser.graphql @@ -5,5 +5,6 @@ query getCurrentUser { lastName email admin + moderator } } diff --git a/ui/src/graphql/queries/listCandidates.graphql b/ui/src/graphql/queries/listCandidates.graphql index d13f620f..43cf743e 100644 --- a/ui/src/graphql/queries/listCandidates.graphql +++ b/ui/src/graphql/queries/listCandidates.graphql @@ -12,6 +12,7 @@ query candidates( minTime maxTime detectionCount + visible feed { id slug @@ -26,6 +27,7 @@ query candidates( playlistTimestamp playerOffset timestamp + visible } } } diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index 5596a873..f5b644a5 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -159,14 +159,6 @@ const DetectionsPage: NextPageWithLayout = () => { .join(", ")} - {/* */} Date: Mon, 11 Dec 2023 16:18:30 -0800 Subject: [PATCH 37/50] Add visible columns to detections, candidates --- ...d_visible_to_candidates_and_detections.exs | 35 +++ .../repo/candidates/20231212001731.json | 160 +++++++++++ .../repo/detections/20231212001731.json | 256 ++++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 server/priv/repo/migrations/20231212001731_add_visible_to_candidates_and_detections.exs create mode 100644 server/priv/resource_snapshots/repo/candidates/20231212001731.json create mode 100644 server/priv/resource_snapshots/repo/detections/20231212001731.json diff --git a/server/priv/repo/migrations/20231212001731_add_visible_to_candidates_and_detections.exs b/server/priv/repo/migrations/20231212001731_add_visible_to_candidates_and_detections.exs new file mode 100644 index 00000000..c2de302d --- /dev/null +++ b/server/priv/repo/migrations/20231212001731_add_visible_to_candidates_and_detections.exs @@ -0,0 +1,35 @@ +defmodule Orcasite.Repo.Migrations.AddVisibleToCandidatesAndDetections do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:detections) do + add :visible, :boolean, default: true + end + + alter table(:candidates) do + add :visible, :boolean, default: true + end + + create index(:detections, [:visible]) + create index(:candidates, [:visible]) + end + + def down do + alter table(:candidates) do + remove :visible + end + + alter table(:detections) do + remove :visible + end + + drop index(:candidates, [:visible]) + drop index(:detections, [:visible]) + end +end diff --git a/server/priv/resource_snapshots/repo/candidates/20231212001731.json b/server/priv/resource_snapshots/repo/candidates/20231212001731.json new file mode 100644 index 00000000..a8312853 --- /dev/null +++ b/server/priv/resource_snapshots/repo/candidates/20231212001731.json @@ -0,0 +1,160 @@ +{ + "attributes": [ + { + "default": "fragment(\"uuid_generate_v7()\")", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "detection_count", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "utc_datetime_usec", + "source": "min_time", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "utc_datetime_usec", + "source": "max_time", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "true", + "size": null, + "type": "boolean", + "source": "visible", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "feed_id", + "references": { + "name": "candidates_feed_id_fkey", + "table": "feeds", + "schema": null, + "on_delete": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "primary_key?": true, + "destination_attribute": "id", + "deferrable": false, + "match_type": null, + "match_with": null, + "on_update": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + } + ], + "table": "candidates", + "hash": "EBE298048887269FE1B17EFA62D1535FB698C23E8A006512ABB30FF85BD7A4C9", + "repo": "Elixir.Orcasite.Repo", + "identities": [], + "schema": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "custom_indexes": [ + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "min_time" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "max_time" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "inserted_at" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + } + ], + "base_filter": null, + "check_constraints": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file diff --git a/server/priv/resource_snapshots/repo/detections/20231212001731.json b/server/priv/resource_snapshots/repo/detections/20231212001731.json new file mode 100644 index 00000000..d4843532 --- /dev/null +++ b/server/priv/resource_snapshots/repo/detections/20231212001731.json @@ -0,0 +1,256 @@ +{ + "attributes": [ + { + "default": "fragment(\"uuid_generate_v7()\")", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "source_ip", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "playlist_timestamp", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "decimal", + "source": "player_offset", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "bigint", + "source": "listener_count", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "utc_datetime_usec", + "source": "timestamp", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "description", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "true", + "size": null, + "type": "boolean", + "source": "visible", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "category", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "candidate_id", + "references": { + "name": "detections_candidate_id_fkey", + "table": "candidates", + "schema": null, + "on_delete": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "primary_key?": true, + "destination_attribute": "id", + "deferrable": false, + "match_type": null, + "match_with": null, + "on_update": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "feed_id", + "references": { + "name": "detections_feed_id_fkey", + "table": "feeds", + "schema": null, + "on_delete": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "primary_key?": true, + "destination_attribute": "id", + "deferrable": false, + "match_type": null, + "match_with": null, + "on_update": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + } + ], + "table": "detections", + "hash": "820195594CB1D958960051895746E31E1949D6571813682FFF29850627A3EBC1", + "repo": "Elixir.Orcasite.Repo", + "identities": [], + "schema": null, + "multitenancy": { + "global": null, + "strategy": null, + "attribute": null + }, + "custom_indexes": [ + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "playlist_timestamp" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "player_offset" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "timestamp" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "description" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + }, + { + "message": null, + "name": null, + "table": null, + "include": null, + "prefix": null, + "fields": [ + "inserted_at" + ], + "where": null, + "unique": false, + "concurrently": false, + "using": null + } + ], + "base_filter": null, + "check_constraints": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file From 6223ec603062c11bd99b86221091bdc071ddcd4f Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 12 Dec 2023 15:49:52 -0800 Subject: [PATCH 38/50] Add actions for moderator hiding/showing detections and candidate --- server/lib/orcasite/radio/detection.ex | 19 ++++++++ ui/src/components/DetectionsTable.tsx | 53 ++++++++++++++++----- ui/src/graphql/generated/index.ts | 2 + ui/src/graphql/queries/getCandidate.graphql | 1 + ui/src/pages/reports/[candidateId].tsx | 23 +++++++-- ui/src/pages/reports/index.tsx | 3 ++ 6 files changed, 83 insertions(+), 18 deletions(-) diff --git a/server/lib/orcasite/radio/detection.ex b/server/lib/orcasite/radio/detection.ex index 736ed16e..0727c393 100644 --- a/server/lib/orcasite/radio/detection.ex +++ b/server/lib/orcasite/radio/detection.ex @@ -120,6 +120,25 @@ defmodule Orcasite.Radio.Detection do argument :visible, :boolean, default: true change set_attribute(:visible, arg(:visible)) + + change fn changeset, _ -> + changeset + |> Ash.Changeset.after_action(fn changeset, detection -> + candidate = + detection + |> Orcasite.Radio.load!(candidate: [:detections]) + |> Map.get(:candidate) + + # If all detections are hidden, make the candidate hidden + candidate + |> Ash.Changeset.for_update(:update, %{ + visible: !Enum.all?(candidate.detections, &(!&1.visible)) + }) + |> Orcasite.Radio.update!() + + {:ok, detection} + end) + end end create :create do diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 149b63dd..0a1a04ee 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -1,6 +1,7 @@ import { Box, Button, + Chip, Table, TableBody, TableCell, @@ -13,6 +14,7 @@ import { Detection, Feed, useGetCurrentUserQuery, + useSetDetectionVisibleMutation, } from "@/graphql/generated"; import { analytics } from "@/utils/analytics"; import { formatTimestamp } from "@/utils/time"; @@ -23,10 +25,12 @@ export default function DetectionsTable({ detections, feed, candidate, + onDetectionUpdate, }: { detections: Detection[]; feed: Pick; - candidate: Pick; + candidate: Pick; + onDetectionUpdate: () => void; }) { const offsetPadding = 15; const minOffset = Math.min(...detections.map((d) => +d.playerOffset)); @@ -35,7 +39,10 @@ export default function DetectionsTable({ const endOffset = maxOffset + offsetPadding; const { currentUser } = useGetCurrentUserQuery().data ?? {}; - console.log("user", currentUser); + + const setDetectionVisible = useSetDetectionVisibleMutation({ + onSuccess: onDetectionUpdate, + }); return ( @@ -66,7 +73,12 @@ export default function DetectionsTable({ Category Description Timestamp - {currentUser?.moderator && Actions} + {currentUser?.moderator && ( + <> + Status + Actions + + )} @@ -74,7 +86,11 @@ export default function DetectionsTable({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) .map((detection, index) => ( - + {index + 1} {detection.id} {feed.slug} @@ -85,15 +101,26 @@ export default function DetectionsTable({ {formatTimestamp(detection.timestamp)} {currentUser?.moderator && ( - - - + <> + + + + + + + )} ))} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index de38e2c9..f0bbce66 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -886,6 +886,7 @@ export type CandidateQuery = { minTime: Date; maxTime: Date; detectionCount?: number | null; + visible?: boolean | null; feed: { __typename?: "Feed"; id: string; @@ -1392,6 +1393,7 @@ export const CandidateDocument = ` minTime maxTime detectionCount + visible feed { id slug diff --git a/ui/src/graphql/queries/getCandidate.graphql b/ui/src/graphql/queries/getCandidate.graphql index 841c3694..4249f599 100644 --- a/ui/src/graphql/queries/getCandidate.graphql +++ b/ui/src/graphql/queries/getCandidate.graphql @@ -4,6 +4,7 @@ query candidate($id: ID!) { minTime maxTime detectionCount + visible feed { id slug diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index 01177415..2bfed9d7 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -1,4 +1,4 @@ -import { Box, Breadcrumbs, Link, Paper, Typography } from "@mui/material"; +import { Box, Breadcrumbs, Chip, Link, Paper, Typography } from "@mui/material"; import Head from "next/head"; import { useRouter } from "next/router"; @@ -35,15 +35,28 @@ const CandidatePage: NextPageWithLayout = () => { - Detections - - {candidate?.id} - + + + Detections + + {candidate?.id} + + + + + + {candidate && ( { + candidatesQuery.refetch(); + }} /> )} diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index f5b644a5..a7de0b8d 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -108,6 +108,9 @@ const DetectionsPage: NextPageWithLayout = () => { detections={selectedCandidate.detections} feed={selectedCandidate.feed} candidate={selectedCandidate} + onDetectionUpdate={() => { + candidatesQuery.refetch(); + }} /> )} From 423e55a5540195a849c514f7880a1ea451bf523e Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 12 Dec 2023 15:58:40 -0800 Subject: [PATCH 39/50] Show visible/hidden chip for moderators on candidate row, modal, and page --- ui/src/components/layouts/ReportsLayout.tsx | 2 +- ui/src/pages/reports/[candidateId].tsx | 13 +++--- ui/src/pages/reports/index.tsx | 47 ++++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/ui/src/components/layouts/ReportsLayout.tsx b/ui/src/components/layouts/ReportsLayout.tsx index 625a842a..b4444800 100644 --- a/ui/src/components/layouts/ReportsLayout.tsx +++ b/ui/src/components/layouts/ReportsLayout.tsx @@ -20,7 +20,7 @@ function ReportsLayout({ children }: { children: React.ReactNode }) { >
- {children} + {children} ); diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index 2bfed9d7..e50495d5 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import DetectionsTable from "@/components/DetectionsTable"; import { getReportsLayout } from "@/components/layouts/ReportsLayout"; -import { useCandidateQuery } from "@/graphql/generated"; +import { useCandidateQuery, useGetCurrentUserQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; @@ -16,6 +16,7 @@ const CandidatePage: NextPageWithLayout = () => { id: (candidateId || "") as string, }); const candidate = candidatesQuery.data?.candidate; + const { currentUser } = useGetCurrentUserQuery().data ?? {}; if (candidateId && typeof candidateId === "string") { analytics.reports.reportOpened(candidateId); @@ -43,10 +44,12 @@ const CandidatePage: NextPageWithLayout = () => { - + {currentUser?.moderator && ( + + )} {candidate && ( diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index a7de0b8d..fa0d97d4 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -1,6 +1,7 @@ import { Box, Button, + Chip, Modal, Paper, Table, @@ -19,7 +20,11 @@ import { useEffect, useMemo, useState } from "react"; import DetectionsTable from "@/components/DetectionsTable"; import { getReportsLayout } from "@/components/layouts/ReportsLayout"; -import { CandidatesQuery, useCandidatesQuery } from "@/graphql/generated"; +import { + CandidatesQuery, + useCandidatesQuery, + useGetCurrentUserQuery, +} from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; import { formatTimestamp } from "@/utils/time"; @@ -51,6 +56,8 @@ const DetectionsPage: NextPageWithLayout = () => { const candidateIdParam = searchParams.get("candidateId"); + const { currentUser } = useGetCurrentUserQuery().data ?? {}; + const [detectionModalOpen, setDetectionModalOpen] = useState(false); const [selectedCandidate, setSelectedCandidate] = useState(); @@ -99,10 +106,24 @@ const DetectionsPage: NextPageWithLayout = () => { - Detections - - Candidate {selectedCandidate?.id} - + + + Detections + + Candidate {selectedCandidate?.id} + + + + {currentUser?.moderator && selectedCandidate && ( + + )} + + {selectedCandidate && ( { Timestamp Categories Descriptions + + {currentUser?.moderator && Status} Actions {candidates.map((candidate) => ( - + {candidate.id} {candidate.feed.slug} @@ -161,6 +188,14 @@ const DetectionsPage: NextPageWithLayout = () => { .slice(0, 3) .join(", ")} + {currentUser?.moderator && ( + + + + )} Date: Tue, 12 Dec 2023 16:06:36 -0800 Subject: [PATCH 40/50] Remove setCandidateVisible mutation --- server/lib/orcasite/radio/candidate.ex | 11 --- ui/src/graphql/generated/index.ts | 82 ------------------- .../mutations/setCandidateVisible.graphql | 8 -- 3 files changed, 101 deletions(-) delete mode 100644 ui/src/graphql/mutations/setCandidateVisible.graphql diff --git a/server/lib/orcasite/radio/candidate.ex b/server/lib/orcasite/radio/candidate.ex index 53184f9b..9ef10afe 100644 --- a/server/lib/orcasite/radio/candidate.ex +++ b/server/lib/orcasite/radio/candidate.ex @@ -124,13 +124,6 @@ defmodule Orcasite.Radio.Candidate do ) end end - - update :set_visible do - accept [:visible] - argument :visible, :boolean, default: true - - change set_attribute(:visible, arg(:visible)) - end end admin do @@ -144,9 +137,5 @@ defmodule Orcasite.Radio.Candidate do get :candidate, :read list :candidates, :index end - - mutations do - update :set_candidate_visible, :set_visible - end end end diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index f0bbce66..d9fd9446 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -483,7 +483,6 @@ export type RootMutationType = { registerWithPassword?: Maybe; requestPasswordReset?: Maybe; resetPassword?: Maybe; - setCandidateVisible?: Maybe; setDetectionVisible?: Maybe; signInWithPassword?: Maybe; signOut?: Maybe; @@ -502,11 +501,6 @@ export type RootMutationTypeResetPasswordArgs = { input: PasswordResetInput; }; -export type RootMutationTypeSetCandidateVisibleArgs = { - id?: InputMaybe; - input?: InputMaybe; -}; - export type RootMutationTypeSetDetectionVisibleArgs = { id?: InputMaybe; input?: InputMaybe; @@ -567,19 +561,6 @@ export type RootQueryTypeFeedsArgs = { sort?: InputMaybe>>; }; -export type SetCandidateVisibleInput = { - visible?: InputMaybe; -}; - -/** The result of the :set_candidate_visible mutation */ -export type SetCandidateVisibleResult = { - __typename?: "SetCandidateVisibleResult"; - /** Any errors generated, if the mutation failed */ - errors?: Maybe>>; - /** The successful result of the mutation */ - result?: Maybe; -}; - export type SetDetectionVisibleInput = { visible?: InputMaybe; }; @@ -788,23 +769,6 @@ export type ResetPasswordMutation = { } | null; }; -export type SetCandidateVisibleMutationVariables = Exact<{ - id: Scalars["ID"]["input"]; - visible: Scalars["Boolean"]["input"]; -}>; - -export type SetCandidateVisibleMutation = { - __typename?: "RootMutationType"; - setCandidateVisible?: { - __typename?: "SetCandidateVisibleResult"; - result?: { - __typename?: "Candidate"; - id: string; - visible?: boolean | null; - } | null; - } | null; -}; - export type SetDetectionVisibleMutationVariables = Exact<{ id: Scalars["ID"]["input"]; visible: Scalars["Boolean"]["input"]; @@ -1158,52 +1122,6 @@ useResetPasswordMutation.fetcher = ( variables, options, ); -export const SetCandidateVisibleDocument = ` - mutation setCandidateVisible($id: ID!, $visible: Boolean!) { - setCandidateVisible(id: $id, input: {visible: $visible}) { - result { - id - visible - } - } -} - `; -export const useSetCandidateVisibleMutation = < - TError = unknown, - TContext = unknown, ->( - options?: UseMutationOptions< - SetCandidateVisibleMutation, - TError, - SetCandidateVisibleMutationVariables, - TContext - >, -) => - useMutation< - SetCandidateVisibleMutation, - TError, - SetCandidateVisibleMutationVariables, - TContext - >( - ["setCandidateVisible"], - (variables?: SetCandidateVisibleMutationVariables) => - fetcher< - SetCandidateVisibleMutation, - SetCandidateVisibleMutationVariables - >(SetCandidateVisibleDocument, variables)(), - options, - ); -useSetCandidateVisibleMutation.getKey = () => ["setCandidateVisible"]; - -useSetCandidateVisibleMutation.fetcher = ( - variables: SetCandidateVisibleMutationVariables, - options?: RequestInit["headers"], -) => - fetcher( - SetCandidateVisibleDocument, - variables, - options, - ); export const SetDetectionVisibleDocument = ` mutation setDetectionVisible($id: ID!, $visible: Boolean!) { setDetectionVisible(id: $id, input: {visible: $visible}) { diff --git a/ui/src/graphql/mutations/setCandidateVisible.graphql b/ui/src/graphql/mutations/setCandidateVisible.graphql deleted file mode 100644 index 96a12f54..00000000 --- a/ui/src/graphql/mutations/setCandidateVisible.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation setCandidateVisible($id: ID!, $visible: Boolean!) { - setCandidateVisible(id: $id, input: { visible: $visible }) { - result { - id - visible - } - } -} From d71c9497faab6c909f1df2fb8f5045869a2df39d Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 13 Dec 2023 15:44:57 -0800 Subject: [PATCH 41/50] Revert router settings --- server/lib/orcasite_web/router.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index d57d9227..1a7dd7dd 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -139,16 +139,16 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through(:nextjs) - # if Mix.env() == :dev do - # get("/*page", OrcasiteWeb.PageController, :index) - # else + if Mix.env() == :dev do + get("/*page", OrcasiteWeb.PageController, :index) + else ui_port = System.get_env("UI_PORT") || "3000" forward("/", ReverseProxyPlug, upstream: "http://localhost:#{ui_port}", error_callback: &__MODULE__.log_reverse_proxy_error/1 ) - # end + end end def log_reverse_proxy_error(error) do From 82633524ef6005a7df2af8b594dc47d280dff65a Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 14 Dec 2023 12:55:39 -0800 Subject: [PATCH 42/50] Add graphql to Notifications API, show notifying and canceling notifications for a candidate. Add auth policies for moderator --- server/lib/orcasite/notifications.ex | 5 +- .../notifications/resources/notification.ex | 89 +++++++++++++------ server/lib/orcasite_web/graphql/schema.ex | 2 +- server/lib/orcasite_web/router.ex | 8 +- server/priv/repo/seeds.exs | 6 +- 5 files changed, 72 insertions(+), 38 deletions(-) diff --git a/server/lib/orcasite/notifications.ex b/server/lib/orcasite/notifications.ex index a60764eb..ab55ba98 100644 --- a/server/lib/orcasite/notifications.ex +++ b/server/lib/orcasite/notifications.ex @@ -1,5 +1,5 @@ defmodule Orcasite.Notifications do - use Ash.Api, extensions: [AshAdmin.Api, AshJsonApi.Api] + use Ash.Api, extensions: [AshAdmin.Api, AshJsonApi.Api, AshGraphql.Api] resources do registry Orcasite.Notifications.Registry @@ -12,4 +12,7 @@ defmodule Orcasite.Notifications do json_api do log_errors? true end + + graphql do + end end diff --git a/server/lib/orcasite/notifications/resources/notification.ex b/server/lib/orcasite/notifications/resources/notification.ex index 62f21eb8..5ae69773 100644 --- a/server/lib/orcasite/notifications/resources/notification.ex +++ b/server/lib/orcasite/notifications/resources/notification.ex @@ -1,7 +1,8 @@ defmodule Orcasite.Notifications.Notification do use Ash.Resource, - extensions: [AshAdmin.Resource], - data_layer: AshPostgres.DataLayer + extensions: [AshAdmin.Resource, AshGraphql.Resource], + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] alias Orcasite.Notifications.{Event, NotificationInstance, Subscription} @@ -34,24 +35,18 @@ defmodule Orcasite.Notifications.Notification do end end - code_interface do - define_for Orcasite.Notifications - - define :notify_new_detection, - action: :notify_new_detection, - args: [:detection_id, :node, :description, :listener_count, :candidate_id] + policies do + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end - define :notify_confirmed_candidate, - action: :notify_confirmed_candidate, - args: [:candidate_id, :node] - end + policy action(:notify_confirmed_candidate) do + authorize_if actor_attribute_equals(:moderator, true) + end - resource do - description """ - Notification for a specific event type. Once created, all Subscriptions that match this Notification's - event type (new detection, confirmed candidate, etc.) will be notified using the Subscription's particular - channel settings (email, browser notification, webhooks). - """ + policy action(:cancel_notification) do + authorize_if actor_attribute_equals(:moderator, true) + end end actions do @@ -65,6 +60,7 @@ defmodule Orcasite.Notifications.Notification do end update :cancel_notification do + accept [] change set_attribute(:active, false) change fn changeset, _context -> @@ -84,9 +80,8 @@ defmodule Orcasite.Notifications.Notification do create :notify_confirmed_candidate do description "Create a notification for confirmed candidate (i.e. detection group)" - accept [:candidate_id] - argument :candidate_id, :integer - argument :node, :string, allow_nil?: false + accept [:candidate_id, :message] + argument :candidate_id, :integer, allow_nil?: false argument :message, :string do description """ @@ -100,10 +95,17 @@ defmodule Orcasite.Notifications.Notification do change set_attribute(:event_type, :confirmed_candidate) change fn changeset, _context -> + candidate_id = Ash.Changeset.get_argument(changeset, :candidate_id) + + candidate = + Orcasite.Radio.Candidate + |> Orcasite.Radio.get(candidate_id) + |> Orcasite.Radio.load!(:feed) + changeset |> Ash.Changeset.change_attribute(:meta, %{ - candidate_id: Ash.Changeset.get_argument(changeset, :candidate_id), - node: Ash.Changeset.get_argument(changeset, :node), + candidate_id: candidate_id, + node: candidate.feed.slug, message: Ash.Changeset.get_argument(changeset, :message) }) end @@ -134,6 +136,36 @@ defmodule Orcasite.Notifications.Notification do end end + code_interface do + define_for Orcasite.Notifications + + define :notify_new_detection, + action: :notify_new_detection, + args: [:detection_id, :node, :description, :listener_count, :candidate_id] + + define :notify_confirmed_candidate, + action: :notify_confirmed_candidate, + args: [:candidate_id] + end + + resource do + description """ + Notification for a specific event type. Once created, all Subscriptions that match this Notification's + event type (new detection, confirmed candidate, etc.) will be notified using the Subscription's particular + channel settings (email, browser notification, webhooks). + """ + end + + admin do + table_columns [:id, :meta, :event_type, :inserted_at] + + format_fields meta: {Jason, :encode!, []} + + form do + field :event_type, type: :default + end + end + changes do change fn changeset, _context -> changeset @@ -162,13 +194,12 @@ defmodule Orcasite.Notifications.Notification do on: :create end - admin do - table_columns [:id, :meta, :event_type, :inserted_at] + graphql do + type :notification - format_fields meta: {Jason, :encode!, []} - - form do - field :event_type, type: :default + mutations do + create :notify_confirmed_candidate, :notify_confirmed_candidate + update :cancel_notification, :cancel_notification end end end diff --git a/server/lib/orcasite_web/graphql/schema.ex b/server/lib/orcasite_web/graphql/schema.ex index 5040acc9..9e8527e6 100644 --- a/server/lib/orcasite_web/graphql/schema.ex +++ b/server/lib/orcasite_web/graphql/schema.ex @@ -1,7 +1,7 @@ defmodule OrcasiteWeb.Schema do use Absinthe.Schema - @apis [Orcasite.Radio, Orcasite.Accounts] + @apis [Orcasite.Radio, Orcasite.Accounts, Orcasite.Notifications] use AshGraphql, apis: @apis diff --git a/server/lib/orcasite_web/router.ex b/server/lib/orcasite_web/router.ex index 1a7dd7dd..d57d9227 100644 --- a/server/lib/orcasite_web/router.ex +++ b/server/lib/orcasite_web/router.ex @@ -139,16 +139,16 @@ defmodule OrcasiteWeb.Router do scope "/" do pipe_through(:nextjs) - if Mix.env() == :dev do - get("/*page", OrcasiteWeb.PageController, :index) - else + # if Mix.env() == :dev do + # get("/*page", OrcasiteWeb.PageController, :index) + # else ui_port = System.get_env("UI_PORT") || "3000" forward("/", ReverseProxyPlug, upstream: "http://localhost:#{ui_port}", error_callback: &__MODULE__.log_reverse_proxy_error/1 ) - end + # end end def log_reverse_proxy_error(error) do diff --git a/server/priv/repo/seeds.exs b/server/priv/repo/seeds.exs index 816f3fde..68a3a967 100644 --- a/server/priv/repo/seeds.exs +++ b/server/priv/repo/seeds.exs @@ -62,7 +62,7 @@ feeds = {:ok, []} -> Orcasite.Radio.Feed |> Ash.Changeset.for_create(:create, attrs) - |> Orcasite.Radio.create!(verbose?: true) + |> Orcasite.Radio.create!(verbose?: true, authorize?: false) {:ok, [feed | _]} -> feed @@ -80,7 +80,7 @@ Orcasite.Accounts.User }) |> Ash.Changeset.force_change_attribute(:admin, true) |> Ash.Changeset.force_change_attribute(:moderator, true) -|> Orcasite.Accounts.create() +|> Orcasite.Accounts.create(authorize?: false) [ %{ @@ -293,5 +293,5 @@ Orcasite.Accounts.User :submit_detection, Map.merge(attrs, %{feed_id: feed_id, send_notifications: false}) ) - |> Orcasite.Radio.create!(verbose?: true) + |> Orcasite.Radio.create!(verbose?: true, authorize?: false) end) From 4585ec663c997df70873b2b3648cbaf8f106966c Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 14 Dec 2023 15:32:31 -0800 Subject: [PATCH 43/50] Add cancel_candidate_notifications mutation for candidate --- .../notifications/resources/notification.ex | 19 ++++++++++- server/lib/orcasite/radio/candidate.ex | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/server/lib/orcasite/notifications/resources/notification.ex b/server/lib/orcasite/notifications/resources/notification.ex index 5ae69773..674a434e 100644 --- a/server/lib/orcasite/notifications/resources/notification.ex +++ b/server/lib/orcasite/notifications/resources/notification.ex @@ -59,6 +59,24 @@ defmodule Orcasite.Notifications.Notification do manual Orcasite.Notifications.ManualReadNotificationsSince end + read :for_candidate do + pagination do + keyset? true + end + + argument :candidate_id, :string, allow_nil?: false + + argument :event_type, :atom do + constraints one_of: Event.types() + default :confirmed_candidate + end + + filter expr( + fragment("(?->'candidate_id' = ?)", meta, ^arg(:candidate_id)) and + event_type == ^arg(:event_type) + ) + end + update :cancel_notification do accept [] change set_attribute(:active, false) @@ -199,7 +217,6 @@ defmodule Orcasite.Notifications.Notification do mutations do create :notify_confirmed_candidate, :notify_confirmed_candidate - update :cancel_notification, :cancel_notification end end end diff --git a/server/lib/orcasite/radio/candidate.ex b/server/lib/orcasite/radio/candidate.ex index 9ef10afe..4d5e7409 100644 --- a/server/lib/orcasite/radio/candidate.ex +++ b/server/lib/orcasite/radio/candidate.ex @@ -5,6 +5,7 @@ defmodule Orcasite.Radio.Candidate do authorizers: [Ash.Policy.Authorizer] alias Orcasite.Radio.{Detection, Feed} + alias Orcasite.Notifications.Event postgres do table "candidates" @@ -124,6 +125,35 @@ defmodule Orcasite.Radio.Candidate do ) end end + + update :cancel_notifications do + accept [] + + argument :event_type, :atom do + constraints one_of: Event.types() + default :confirmed_candidate + end + + change fn changeset, _context -> + changeset + |> Ash.Changeset.after_action(fn _, record -> + Orcasite.Notifications.Notification + |> Ash.Query.for_read(:for_candidate, %{ + candidate_id: record.id, + event_type: Ash.Changeset.get_argument(changeset, :event_type) + }) + |> Orcasite.Notifications.stream!() + |> Stream.map(fn notification -> + notification + |> Ash.Changeset.for_update(:cancel_notification, %{}) + |> Orcasite.Notifications.update!() + end) + |> Stream.run() + + {:ok, record} + end) + end + end end admin do @@ -137,5 +167,9 @@ defmodule Orcasite.Radio.Candidate do get :candidate, :read list :candidates, :index end + + mutations do + update :cancel_candidate_notifications, :cancel_notifications + end end end From 144d6ca0623326122f4159d4d9c10257c9be100e Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Fri, 15 Dec 2023 19:02:34 +0000 Subject: [PATCH 44/50] Refactor to use auth layout --- ui/src/pages/sign-out.tsx | 66 ++++++--------------------------------- 1 file changed, 9 insertions(+), 57 deletions(-) diff --git a/ui/src/pages/sign-out.tsx b/ui/src/pages/sign-out.tsx index eb5d0bf0..67fe960c 100644 --- a/ui/src/pages/sign-out.tsx +++ b/ui/src/pages/sign-out.tsx @@ -1,15 +1,13 @@ -import { Box, Paper, Typography } from "@mui/material"; +import { Typography } from "@mui/material"; import Head from "next/head"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import Header from "@/components/Header"; +import { getAuthLayout } from "@/components/layouts/AuthLayout"; import { useSignOutMutation } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; -import logo from "@/public/wordmark/wordmark-teal.svg"; -const SignInPage: NextPageWithLayout = () => { +const SignOutPage: NextPageWithLayout = () => { const router = useRouter(); const signOut = useSignOutMutation({ onSuccess: () => { @@ -28,60 +26,14 @@ const SignInPage: NextPageWithLayout = () => {
- -
- - - - Orcasound - - - - - Signing out... - - - - - + + Signing out... +
); }; -export default SignInPage; +SignOutPage.getLayout = getAuthLayout; + +export default SignOutPage; From 1e94e39353321a89229703d267630d1ae985a1ce Mon Sep 17 00:00:00 2001 From: Paul Cretu Date: Fri, 15 Dec 2023 19:12:14 +0000 Subject: [PATCH 45/50] Fix useEffect deps array --- ui/src/pages/sign-out.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/sign-out.tsx b/ui/src/pages/sign-out.tsx index 67fe960c..0aa4c022 100644 --- a/ui/src/pages/sign-out.tsx +++ b/ui/src/pages/sign-out.tsx @@ -9,15 +9,15 @@ import type { NextPageWithLayout } from "@/pages/_app"; const SignOutPage: NextPageWithLayout = () => { const router = useRouter(); - const signOut = useSignOutMutation({ + const { mutate: signOutMutate } = useSignOutMutation({ onSuccess: () => { router.push("/"); }, }); useEffect(() => { - signOut.mutate({}); - }, []); + signOutMutate({}); + }, [signOutMutate]); return (
From 967bbb117726531ceb4caea2edcdca1aafe12f74 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 18 Dec 2023 11:28:59 -0800 Subject: [PATCH 46/50] Add indexes to meta columns in notifications, subscribers, subscriptions. Add new cancel, notify candidate mutations in frontend --- server/config/config.exs | 1 + .../notifications/resources/notification.ex | 40 +-- .../resources/notification_instance.ex | 2 +- .../notifications/resources/subscriber.ex | 146 +++++------ .../notifications/resources/subscription.ex | 110 +++++---- server/lib/orcasite/radio/candidate.ex | 8 +- ...1215195951_add_indexes_to_meta_columns.exs | 49 ++++ .../repo/notifications/20231215195951.json | 94 +++++++ .../repo/subscribers/20231215195951.json | 103 ++++++++ .../repo/subscriptions/20231215195951.json | 179 ++++++++++++++ ui/src/components/DetectionsTable.tsx | 3 + ui/src/graphql/generated/index.ts | 229 ++++++++++++++++++ .../cancelCandidateNotifications.graphql | 14 ++ .../notifyConfirmedCandidate.graphql | 19 ++ .../mutations/setDetectionVisible.graphql | 7 + 15 files changed, 860 insertions(+), 144 deletions(-) create mode 100644 server/priv/repo/migrations/20231215195951_add_indexes_to_meta_columns.exs create mode 100644 server/priv/resource_snapshots/repo/notifications/20231215195951.json create mode 100644 server/priv/resource_snapshots/repo/subscribers/20231215195951.json create mode 100644 server/priv/resource_snapshots/repo/subscriptions/20231215195951.json create mode 100644 ui/src/graphql/mutations/cancelCandidateNotifications.graphql create mode 100644 ui/src/graphql/mutations/notifyConfirmedCandidate.graphql diff --git a/server/config/config.exs b/server/config/config.exs index 73873373..55c00748 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -108,6 +108,7 @@ config :spark, :formatter, :authentication, :token, :policies, + :field_policies, :actions, :admin, :json_api, diff --git a/server/lib/orcasite/notifications/resources/notification.ex b/server/lib/orcasite/notifications/resources/notification.ex index 674a434e..fc62e901 100644 --- a/server/lib/orcasite/notifications/resources/notification.ex +++ b/server/lib/orcasite/notifications/resources/notification.ex @@ -9,12 +9,16 @@ defmodule Orcasite.Notifications.Notification do postgres do table "notifications" repo Orcasite.Repo + + custom_indexes do + index [:meta], using: "gin" + end end attributes do uuid_primary_key :id - attribute :meta, :map + attribute :meta, :map, default: %{} attribute :active, :boolean, default: true attribute :event_type, :atom do @@ -40,12 +44,10 @@ defmodule Orcasite.Notifications.Notification do authorize_if always() end - policy action(:notify_confirmed_candidate) do - authorize_if actor_attribute_equals(:moderator, true) - end - - policy action(:cancel_notification) do - authorize_if actor_attribute_equals(:moderator, true) + bypass actor_attribute_equals(:moderator, true) do + authorize_if action(:notify_confirmed_candidate) + authorize_if action(:cancel_notification) + authorize_if action(:for_candidate) end end @@ -60,20 +62,24 @@ defmodule Orcasite.Notifications.Notification do end read :for_candidate do - pagination do - keyset? true - end - argument :candidate_id, :string, allow_nil?: false argument :event_type, :atom do constraints one_of: Event.types() - default :confirmed_candidate end + argument :active, :boolean + filter expr( - fragment("(?->'candidate_id' = ?)", meta, ^arg(:candidate_id)) and - event_type == ^arg(:event_type) + fragment("(? @> ?)", meta, expr(%{candidate_id: ^arg(:candidate_id)})) and + if(not is_nil(^arg(:event_type)), + do: event_type == ^arg(:event_type), + else: true + ) and + if(not is_nil(^arg(:active)), + do: active == ^arg(:active), + else: true + ) ) end @@ -99,7 +105,7 @@ defmodule Orcasite.Notifications.Notification do create :notify_confirmed_candidate do description "Create a notification for confirmed candidate (i.e. detection group)" accept [:candidate_id, :message] - argument :candidate_id, :integer, allow_nil?: false + argument :candidate_id, :uuid, allow_nil?: false argument :message, :string do description """ @@ -215,6 +221,10 @@ defmodule Orcasite.Notifications.Notification do graphql do type :notification + queries do + list :notifications_for_candidate, :for_candidate + end + mutations do create :notify_confirmed_candidate, :notify_confirmed_candidate end diff --git a/server/lib/orcasite/notifications/resources/notification_instance.ex b/server/lib/orcasite/notifications/resources/notification_instance.ex index f3fa05c4..35fe8de9 100644 --- a/server/lib/orcasite/notifications/resources/notification_instance.ex +++ b/server/lib/orcasite/notifications/resources/notification_instance.ex @@ -9,7 +9,7 @@ defmodule Orcasite.Notifications.NotificationInstance do attributes do uuid_primary_key :id - attribute :meta, :map + attribute :meta, :map, default: %{} attribute :channel, :atom do constraints one_of: [:email] diff --git a/server/lib/orcasite/notifications/resources/subscriber.ex b/server/lib/orcasite/notifications/resources/subscriber.ex index 2aed793c..020943a2 100644 --- a/server/lib/orcasite/notifications/resources/subscriber.ex +++ b/server/lib/orcasite/notifications/resources/subscriber.ex @@ -5,10 +5,13 @@ defmodule Orcasite.Notifications.Subscriber do alias Orcasite.Notifications.{Subscription} - resource do - description """ - A subscriber object. Can relate to an individual, an organization, a newsletter, or an admin. - """ + postgres do + table "subscribers" + repo Orcasite.Repo + + custom_indexes do + index [:meta], using: "gin" + end end identities do @@ -16,24 +19,54 @@ defmodule Orcasite.Notifications.Subscriber do identity :id, [:id] end - code_interface do - define_for Orcasite.Notifications - define :by_email, args: [:email] + attributes do + uuid_primary_key :id + + attribute :name, :string + + attribute :subscriber_type, :atom do + constraints one_of: [:individual, :organization] + end + + attribute :meta, :map, default: %{} + + create_timestamp :inserted_at + update_timestamp :updated_at end - validations do - validate fn changeset -> - # Check if email subscriber already exists - with email when is_binary(email) <- changeset |> Ash.Changeset.get_argument(:email), - %{action_type: :create} <- changeset, - {:get, {:error, _}} <- {:get, Orcasite.Notifications.Subscriber.by_email(email)} do - :ok - else - {:get, other} -> - {:error, [field: :email, message: "email already exists"]} + relationships do + has_many :subscriptions, Subscription + end - err -> + authentication do + api Orcasite.Notifications + + strategies do + magic_link :manage_subscriptions do + identity_field :id + + single_use_token? false + # 14 days (in minutes) + token_lifetime 1_209_600 + + sender fn _subscriber, _token, _opts -> + # IO.inspect({subscriber, token}, + # label: + # "subscriber/token (server/lib/orcasite/notifications/resources/subscriber.ex:#{__ENV__.line})" + # ) + + # Orcasite.Emails.deliver_magic_link(user, token) :ok + end + end + end + + tokens do + enabled? true + token_resource Orcasite.Notifications.Token + + signing_secret fn _, _ -> + {:ok, Application.get_env(:orcasite, OrcasiteWeb.Endpoint)[:secret_key_base]} end end end @@ -107,61 +140,15 @@ defmodule Orcasite.Notifications.Subscriber do end end - postgres do - table "subscribers" - repo Orcasite.Repo - end - - authentication do - api Orcasite.Notifications - - strategies do - magic_link :manage_subscriptions do - identity_field :id - - single_use_token? false - # 14 days (in minutes) - token_lifetime 1_209_600 - - sender fn _subscriber, _token, _opts -> - # IO.inspect({subscriber, token}, - # label: - # "subscriber/token (server/lib/orcasite/notifications/resources/subscriber.ex:#{__ENV__.line})" - # ) - - # Orcasite.Emails.deliver_magic_link(user, token) - :ok - end - end - end - - tokens do - enabled? true - token_resource Orcasite.Notifications.Token - - signing_secret fn _, _ -> - {:ok, Application.get_env(:orcasite, OrcasiteWeb.Endpoint)[:secret_key_base]} - end - end - end - - attributes do - uuid_primary_key :id - - attribute :name, :string - - attribute :subscriber_type, :atom do - constraints one_of: [:individual, :organization] - end - - attribute :meta, :map - - create_timestamp :inserted_at - update_timestamp :updated_at + code_interface do + define_for Orcasite.Notifications + define :by_email, args: [:email] end - relationships do - has_many :subscriptions, Subscription + resource do + description """ + A subscriber object. Can relate to an individual, an organization, a newsletter, or an admin. + """ end admin do @@ -174,6 +161,23 @@ defmodule Orcasite.Notifications.Subscriber do end end + validations do + validate fn changeset -> + # Check if email subscriber already exists + with email when is_binary(email) <- changeset |> Ash.Changeset.get_argument(:email), + %{action_type: :create} <- changeset, + {:get, {:error, _}} <- {:get, Orcasite.Notifications.Subscriber.by_email(email)} do + :ok + else + {:get, other} -> + {:error, [field: :email, message: "email already exists"]} + + err -> + :ok + end + end + end + json_api do type "subscriber" diff --git a/server/lib/orcasite/notifications/resources/subscription.ex b/server/lib/orcasite/notifications/resources/subscription.ex index 2c8f419e..bc0e2286 100644 --- a/server/lib/orcasite/notifications/resources/subscription.ex +++ b/server/lib/orcasite/notifications/resources/subscription.ex @@ -5,11 +5,13 @@ defmodule Orcasite.Notifications.Subscription do alias Orcasite.Notifications.{Event, Notification, NotificationInstance, Subscriber} - resource do - description """ - A subscription - relates a subscriber to a notification type and a channel. - (i.e. subscribing to :new_detection via :email) - """ + postgres do + table "subscriptions" + repo Orcasite.Repo + + custom_indexes do + index [:meta], using: "gin" + end end identities do @@ -17,6 +19,37 @@ defmodule Orcasite.Notifications.Subscription do identity :id, [:id] end + attributes do + uuid_primary_key :id + + attribute :name, :string + attribute :meta, :map, default: %{} + + attribute :active, :boolean, default: true + + attribute :event_type, :atom do + constraints one_of: Event.types() + end + + attribute :last_notified_at, :utc_datetime_usec + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :subscriber, Subscriber + has_many :notification_instances, NotificationInstance + + many_to_many :notifications, Notification do + through NotificationInstance + source_attribute_on_join_resource :subscription_id + destination_attribute_on_join_resource :notification_id + end + + belongs_to :last_notification, Notification + end + authentication do api Orcasite.Notifications @@ -50,6 +83,25 @@ defmodule Orcasite.Notifications.Subscription do end end + code_interface do + define_for Orcasite.Notifications + + define :update_last_notification, + action: :update_last_notification, + args: [:last_notification] + + define :available_for_notification, + action: :available_for_notification, + args: [:notification_id, :event_type, {:optional, :minutes_ago}] + end + + resource do + description """ + A subscription - relates a subscriber to a notification type and a channel. + (i.e. subscribing to :new_detection via :email) + """ + end + actions do defaults [:create, :read, :destroy] @@ -126,54 +178,6 @@ defmodule Orcasite.Notifications.Subscription do end end - code_interface do - define_for Orcasite.Notifications - - define :update_last_notification, - action: :update_last_notification, - args: [:last_notification] - - define :available_for_notification, - action: :available_for_notification, - args: [:notification_id, :event_type, {:optional, :minutes_ago}] - end - - postgres do - table "subscriptions" - repo Orcasite.Repo - end - - attributes do - uuid_primary_key :id - - attribute :name, :string - attribute :meta, :map - - attribute :active, :boolean, default: true - - attribute :event_type, :atom do - constraints one_of: Event.types() - end - - attribute :last_notified_at, :utc_datetime_usec - - create_timestamp :inserted_at - update_timestamp :updated_at - end - - relationships do - belongs_to :subscriber, Subscriber - has_many :notification_instances, NotificationInstance - - many_to_many :notifications, Notification do - through NotificationInstance - source_attribute_on_join_resource :subscription_id - destination_attribute_on_join_resource :notification_id - end - - belongs_to :last_notification, Notification - end - def unsubscribe_token(subscription) do strategy = AshAuthentication.Info.strategy!(Orcasite.Notifications.Subscription, :unsubscribe) {:ok, token} = AshAuthentication.Strategy.MagicLink.request_token_for(strategy, subscription) diff --git a/server/lib/orcasite/radio/candidate.ex b/server/lib/orcasite/radio/candidate.ex index 4d5e7409..95751ed4 100644 --- a/server/lib/orcasite/radio/candidate.ex +++ b/server/lib/orcasite/radio/candidate.ex @@ -140,15 +140,15 @@ defmodule Orcasite.Radio.Candidate do Orcasite.Notifications.Notification |> Ash.Query.for_read(:for_candidate, %{ candidate_id: record.id, - event_type: Ash.Changeset.get_argument(changeset, :event_type) + event_type: Ash.Changeset.get_argument(changeset, :event_type), + active: true }) - |> Orcasite.Notifications.stream!() - |> Stream.map(fn notification -> + |> Orcasite.Notifications.read!() + |> Enum.map(fn notification -> notification |> Ash.Changeset.for_update(:cancel_notification, %{}) |> Orcasite.Notifications.update!() end) - |> Stream.run() {:ok, record} end) diff --git a/server/priv/repo/migrations/20231215195951_add_indexes_to_meta_columns.exs b/server/priv/repo/migrations/20231215195951_add_indexes_to_meta_columns.exs new file mode 100644 index 00000000..fbc551fc --- /dev/null +++ b/server/priv/repo/migrations/20231215195951_add_indexes_to_meta_columns.exs @@ -0,0 +1,49 @@ +defmodule Orcasite.Repo.Migrations.AddIndexesToMetaColumns do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create index(:subscriptions, ["meta"], using: "gin") + + alter table(:subscriptions) do + modify :meta, :map, default: %{} + end + + create index(:subscribers, ["meta"], using: "gin") + + alter table(:subscribers) do + modify :meta, :map, default: %{} + end + + create index(:notifications, ["meta"], using: "gin") + + alter table(:notifications) do + modify :meta, :map, default: %{} + end + end + + def down do + alter table(:notifications) do + modify :meta, :map, default: nil + end + + drop_if_exists index(:notifications, ["meta"], name: "notifications_meta_index") + + alter table(:subscribers) do + modify :meta, :map, default: nil + end + + drop_if_exists index(:subscribers, ["meta"], name: "subscribers_meta_index") + + alter table(:subscriptions) do + modify :meta, :map, default: nil + end + + drop_if_exists index(:subscriptions, ["meta"], name: "subscriptions_meta_index") + end +end \ No newline at end of file diff --git a/server/priv/resource_snapshots/repo/notifications/20231215195951.json b/server/priv/resource_snapshots/repo/notifications/20231215195951.json new file mode 100644 index 00000000..496e83db --- /dev/null +++ b/server/priv/resource_snapshots/repo/notifications/20231215195951.json @@ -0,0 +1,94 @@ +{ + "attributes": [ + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "%{}", + "size": null, + "type": "map", + "source": "meta", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "true", + "size": null, + "type": "boolean", + "source": "active", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "event_type", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + } + ], + "table": "notifications", + "hash": "21BB389756E5FCF195EAA6754159D62F2CC383F81AD7032AABDC39179CBAB306", + "repo": "Elixir.Orcasite.Repo", + "identities": [], + "schema": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "custom_indexes": [ + { + "message": null, + "name": null, + "table": null, + "include": null, + "fields": [ + "meta" + ], + "prefix": null, + "where": null, + "unique": false, + "concurrently": false, + "using": "gin" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file diff --git a/server/priv/resource_snapshots/repo/subscribers/20231215195951.json b/server/priv/resource_snapshots/repo/subscribers/20231215195951.json new file mode 100644 index 00000000..a5f9cf0d --- /dev/null +++ b/server/priv/resource_snapshots/repo/subscribers/20231215195951.json @@ -0,0 +1,103 @@ +{ + "attributes": [ + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "name", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "subscriber_type", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "%{}", + "size": null, + "type": "map", + "source": "meta", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + } + ], + "table": "subscribers", + "hash": "F84FC8AB7D0D5CA325085E8FBE1382552C506FC0D613DF53C733F90D6FAD8929", + "repo": "Elixir.Orcasite.Repo", + "identities": [ + { + "name": "id", + "keys": [ + "id" + ], + "index_name": "subscribers_id_index", + "base_filter": null + } + ], + "schema": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "custom_indexes": [ + { + "message": null, + "name": null, + "table": null, + "include": null, + "fields": [ + "meta" + ], + "prefix": null, + "where": null, + "unique": false, + "concurrently": false, + "using": "gin" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file diff --git a/server/priv/resource_snapshots/repo/subscriptions/20231215195951.json b/server/priv/resource_snapshots/repo/subscriptions/20231215195951.json new file mode 100644 index 00000000..95523ebf --- /dev/null +++ b/server/priv/resource_snapshots/repo/subscriptions/20231215195951.json @@ -0,0 +1,179 @@ +{ + "attributes": [ + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "id", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": true + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "name", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "%{}", + "size": null, + "type": "map", + "source": "meta", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "true", + "size": null, + "type": "boolean", + "source": "active", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "text", + "source": "event_type", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "utc_datetime_usec", + "source": "last_notified_at", + "references": null, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "inserted_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "fragment(\"now()\")", + "size": null, + "type": "utc_datetime_usec", + "source": "updated_at", + "references": null, + "allow_nil?": false, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "subscriber_id", + "references": { + "name": "subscriptions_subscriber_id_fkey", + "table": "subscribers", + "schema": null, + "on_delete": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "primary_key?": true, + "destination_attribute": "id", + "deferrable": false, + "match_type": null, + "match_with": null, + "on_update": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + }, + { + "default": "nil", + "size": null, + "type": "uuid", + "source": "last_notification_id", + "references": { + "name": "subscriptions_last_notification_id_fkey", + "table": "notifications", + "schema": null, + "on_delete": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "primary_key?": true, + "destination_attribute": "id", + "deferrable": false, + "match_type": null, + "match_with": null, + "on_update": null, + "destination_attribute_default": null, + "destination_attribute_generated": null + }, + "allow_nil?": true, + "generated?": false, + "primary_key?": false + } + ], + "table": "subscriptions", + "hash": "26D124B5A6C7A95470CAFF5B4A1ECF606D3F653D715C81FA2035DB696E83583D", + "repo": "Elixir.Orcasite.Repo", + "identities": [ + { + "name": "id", + "keys": [ + "id" + ], + "index_name": "subscriptions_id_index", + "base_filter": null + } + ], + "schema": null, + "multitenancy": { + "global": null, + "attribute": null, + "strategy": null + }, + "custom_indexes": [ + { + "message": null, + "name": null, + "table": null, + "include": null, + "fields": [ + "meta" + ], + "prefix": null, + "where": null, + "unique": false, + "concurrently": false, + "using": "gin" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_statements": [], + "has_create_action": true +} \ No newline at end of file diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 0a1a04ee..a2c00a69 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -14,6 +14,7 @@ import { Detection, Feed, useGetCurrentUserQuery, + useNotifyConfirmedCandidateMutation, useSetDetectionVisibleMutation, } from "@/graphql/generated"; import { analytics } from "@/utils/analytics"; @@ -44,6 +45,8 @@ export default function DetectionsTable({ onSuccess: onDetectionUpdate, }); + const notifyConfirmedCandidate = useNotifyConfirmedCandidateMutation(); + return ( ; +}; + +/** The result of the :cancel_candidate_notifications mutation */ +export type CancelCandidateNotificationsResult = { + __typename?: "CancelCandidateNotificationsResult"; + /** Any errors generated, if the mutation failed */ + errors?: Maybe>>; + /** The successful result of the mutation */ + result?: Maybe; +}; + export type Candidate = { __typename?: "Candidate"; detectionCount?: Maybe; @@ -56,6 +69,8 @@ export type CandidateDetectionsArgs = { sort?: InputMaybe>>; }; +export type CandidateEventType = "CONFIRMED_CANDIDATE" | "NEW_DETECTION"; + export type CandidateFilterDetectionCount = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -412,6 +427,39 @@ export type MutationError = { vars?: Maybe; }; +/** + * Notification for a specific event type. Once created, all Subscriptions that match this Notification's + * event type (new detection, confirmed candidate, etc.) will be notified using the Subscription's particular + * channel settings (email, browser notification, webhooks). + */ +export type Notification = { + __typename?: "Notification"; + active?: Maybe; + eventType?: Maybe; + id: Scalars["ID"]["output"]; + meta?: Maybe; +}; + +export type NotificationEventType = "CONFIRMED_CANDIDATE" | "NEW_DETECTION"; + +export type NotifyConfirmedCandidateInput = { + candidateId: Scalars["ID"]["input"]; + /** + * What primary message subscribers will get (e.g. 'Southern Resident Killer Whales calls + * and clicks can be heard at Orcasound Lab!') + */ + message: Scalars["String"]["input"]; +}; + +/** The result of the :notify_confirmed_candidate mutation */ +export type NotifyConfirmedCandidateResult = { + __typename?: "NotifyConfirmedCandidateResult"; + /** Any errors generated, if the mutation failed */ + errors?: Maybe>>; + /** The successful result of the mutation */ + result?: Maybe; +}; + /** A page of :candidate */ export type PageOfCandidate = { __typename?: "PageOfCandidate"; @@ -479,6 +527,9 @@ export type RequestPasswordResetInput = { export type RootMutationType = { __typename?: "RootMutationType"; + cancelCandidateNotifications?: Maybe; + /** Create a notification for confirmed candidate (i.e. detection group) */ + notifyConfirmedCandidate?: Maybe; /** Register a new user with a username and password. */ registerWithPassword?: Maybe; requestPasswordReset?: Maybe; @@ -489,6 +540,15 @@ export type RootMutationType = { submitDetection?: Maybe; }; +export type RootMutationTypeCancelCandidateNotificationsArgs = { + id?: InputMaybe; + input?: InputMaybe; +}; + +export type RootMutationTypeNotifyConfirmedCandidateArgs = { + input?: InputMaybe; +}; + export type RootMutationTypeRegisterWithPasswordArgs = { input?: InputMaybe; }; @@ -700,6 +760,53 @@ export type UserFilterModerator = { notEq?: InputMaybe; }; +export type CancelCandidateNotificationsMutationVariables = Exact<{ + candidateId: Scalars["ID"]["input"]; +}>; + +export type CancelCandidateNotificationsMutation = { + __typename?: "RootMutationType"; + cancelCandidateNotifications?: { + __typename?: "CancelCandidateNotificationsResult"; + result?: { __typename?: "Candidate"; id: string } | null; + errors?: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; + } | null; +}; + +export type NotifyConfirmedCandidateMutationVariables = Exact<{ + candidateId: Scalars["ID"]["input"]; + message: Scalars["String"]["input"]; +}>; + +export type NotifyConfirmedCandidateMutation = { + __typename?: "RootMutationType"; + notifyConfirmedCandidate?: { + __typename?: "NotifyConfirmedCandidateResult"; + result?: { + __typename?: "Notification"; + id: string; + eventType?: NotificationEventType | null; + meta?: { [key: string]: any } | null; + active?: boolean | null; + } | null; + errors?: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; + } | null; +}; + export type RegisterWithPasswordMutationVariables = Exact<{ firstName?: InputMaybe; lastName?: InputMaybe; @@ -783,6 +890,14 @@ export type SetDetectionVisibleMutation = { id: string; visible?: boolean | null; } | null; + errors?: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; } | null; }; @@ -968,6 +1083,113 @@ export type FeedsQuery = { }>; }; +export const CancelCandidateNotificationsDocument = ` + mutation cancelCandidateNotifications($candidateId: ID!) { + cancelCandidateNotifications(id: $candidateId) { + result { + id + } + errors { + code + fields + message + shortMessage + vars + } + } +} + `; +export const useCancelCandidateNotificationsMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + CancelCandidateNotificationsMutation, + TError, + CancelCandidateNotificationsMutationVariables, + TContext + >, +) => + useMutation< + CancelCandidateNotificationsMutation, + TError, + CancelCandidateNotificationsMutationVariables, + TContext + >( + ["cancelCandidateNotifications"], + (variables?: CancelCandidateNotificationsMutationVariables) => + fetcher< + CancelCandidateNotificationsMutation, + CancelCandidateNotificationsMutationVariables + >(CancelCandidateNotificationsDocument, variables)(), + options, + ); +useCancelCandidateNotificationsMutation.getKey = () => [ + "cancelCandidateNotifications", +]; + +useCancelCandidateNotificationsMutation.fetcher = ( + variables: CancelCandidateNotificationsMutationVariables, + options?: RequestInit["headers"], +) => + fetcher< + CancelCandidateNotificationsMutation, + CancelCandidateNotificationsMutationVariables + >(CancelCandidateNotificationsDocument, variables, options); +export const NotifyConfirmedCandidateDocument = ` + mutation notifyConfirmedCandidate($candidateId: ID!, $message: String!) { + notifyConfirmedCandidate(input: {candidateId: $candidateId, message: $message}) { + result { + id + eventType + meta + active + } + errors { + code + fields + message + shortMessage + vars + } + } +} + `; +export const useNotifyConfirmedCandidateMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + NotifyConfirmedCandidateMutation, + TError, + NotifyConfirmedCandidateMutationVariables, + TContext + >, +) => + useMutation< + NotifyConfirmedCandidateMutation, + TError, + NotifyConfirmedCandidateMutationVariables, + TContext + >( + ["notifyConfirmedCandidate"], + (variables?: NotifyConfirmedCandidateMutationVariables) => + fetcher< + NotifyConfirmedCandidateMutation, + NotifyConfirmedCandidateMutationVariables + >(NotifyConfirmedCandidateDocument, variables)(), + options, + ); +useNotifyConfirmedCandidateMutation.getKey = () => ["notifyConfirmedCandidate"]; + +useNotifyConfirmedCandidateMutation.fetcher = ( + variables: NotifyConfirmedCandidateMutationVariables, + options?: RequestInit["headers"], +) => + fetcher< + NotifyConfirmedCandidateMutation, + NotifyConfirmedCandidateMutationVariables + >(NotifyConfirmedCandidateDocument, variables, options); export const RegisterWithPasswordDocument = ` mutation registerWithPassword($firstName: String, $lastName: String, $email: String!, $password: String!, $passwordConfirmation: String!) { registerWithPassword( @@ -1129,6 +1351,13 @@ export const SetDetectionVisibleDocument = ` id visible } + errors { + code + fields + message + shortMessage + vars + } } } `; diff --git a/ui/src/graphql/mutations/cancelCandidateNotifications.graphql b/ui/src/graphql/mutations/cancelCandidateNotifications.graphql new file mode 100644 index 00000000..b105d427 --- /dev/null +++ b/ui/src/graphql/mutations/cancelCandidateNotifications.graphql @@ -0,0 +1,14 @@ +mutation cancelCandidateNotifications($candidateId: ID!) { + cancelCandidateNotifications(id: $candidateId) { + result { + id + } + errors { + code + fields + message + shortMessage + vars + } + } +} \ No newline at end of file diff --git a/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql b/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql new file mode 100644 index 00000000..da551061 --- /dev/null +++ b/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql @@ -0,0 +1,19 @@ +mutation notifyConfirmedCandidate($candidateId: ID!, $message: String!) { + notifyConfirmedCandidate( + input: { candidateId: $candidateId, message: $message } + ) { + result { + id + eventType + meta + active + } + errors { + code + fields + message + shortMessage + vars + } + } +} diff --git a/ui/src/graphql/mutations/setDetectionVisible.graphql b/ui/src/graphql/mutations/setDetectionVisible.graphql index 6b96ad08..2d4c4119 100644 --- a/ui/src/graphql/mutations/setDetectionVisible.graphql +++ b/ui/src/graphql/mutations/setDetectionVisible.graphql @@ -4,5 +4,12 @@ mutation setDetectionVisible($id: ID!, $visible: Boolean!) { id visible } + errors { + code + fields + message + shortMessage + vars + } } } From 14433920bd4b8d401e43d27b92d8dfe5fd753f17 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 18 Dec 2023 11:35:50 -0800 Subject: [PATCH 47/50] Invalidate candidate query on detection update --- ui/src/components/DetectionsTable.tsx | 12 +++++++++--- ui/src/pages/reports/[candidateId].tsx | 3 --- ui/src/pages/reports/index.tsx | 3 --- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 0a1a04ee..2a307c6f 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -8,11 +8,13 @@ import { TableHead, TableRow, } from "@mui/material"; +import { useQueryClient } from "@tanstack/react-query"; import { Candidate, Detection, Feed, + useCandidateQuery, useGetCurrentUserQuery, useSetDetectionVisibleMutation, } from "@/graphql/generated"; @@ -25,12 +27,10 @@ export default function DetectionsTable({ detections, feed, candidate, - onDetectionUpdate, }: { detections: Detection[]; feed: Pick; candidate: Pick; - onDetectionUpdate: () => void; }) { const offsetPadding = 15; const minOffset = Math.min(...detections.map((d) => +d.playerOffset)); @@ -40,8 +40,14 @@ export default function DetectionsTable({ const { currentUser } = useGetCurrentUserQuery().data ?? {}; + const queryClient = useQueryClient(); + const setDetectionVisible = useSetDetectionVisibleMutation({ - onSuccess: onDetectionUpdate, + onSuccess: () => { + queryClient.invalidateQueries( + useCandidateQuery.getKey({ id: candidate.id }), + ); + }, }); return ( diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index e50495d5..97299284 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -57,9 +57,6 @@ const CandidatePage: NextPageWithLayout = () => { detections={candidate.detections} feed={candidate.feed} candidate={candidate} - onDetectionUpdate={() => { - candidatesQuery.refetch(); - }} /> )} diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index fa0d97d4..cde969c5 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -129,9 +129,6 @@ const DetectionsPage: NextPageWithLayout = () => { detections={selectedCandidate.detections} feed={selectedCandidate.feed} candidate={selectedCandidate} - onDetectionUpdate={() => { - candidatesQuery.refetch(); - }} /> )} From 6d95c6c3babc5fb34cddef5e11971f60e309b357 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 18 Dec 2023 11:47:49 -0800 Subject: [PATCH 48/50] Re-add missing sign out mutation --- ui/src/graphql/generated/index.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index f39e2ad2..d8e9b148 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -737,6 +737,13 @@ export type SignInWithPasswordMutation = { } | null; }; +export type SignOutMutationVariables = Exact<{ [key: string]: never }>; + +export type SignOutMutation = { + __typename?: "RootMutationType"; + signOut?: boolean | null; +}; + export type SubmitDetectionMutationVariables = Exact<{ feedId: Scalars["String"]["input"]; playlistTimestamp: Scalars["Int"]["input"]; @@ -1089,6 +1096,39 @@ useSignInWithPasswordMutation.fetcher = ( variables, options, ); +export const SignOutDocument = ` + mutation signOut { + signOut +} + `; +export const useSignOutMutation = ( + options?: UseMutationOptions< + SignOutMutation, + TError, + SignOutMutationVariables, + TContext + >, +) => + useMutation( + ["signOut"], + (variables?: SignOutMutationVariables) => + fetcher( + SignOutDocument, + variables, + )(), + options, + ); +useSignOutMutation.getKey = () => ["signOut"]; + +useSignOutMutation.fetcher = ( + variables?: SignOutMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + SignOutDocument, + variables, + options, + ); export const SubmitDetectionDocument = ` mutation submitDetection($feedId: String!, $playlistTimestamp: Int!, $playerOffset: Decimal!, $description: String!, $listenerCount: Int, $category: DetectionCategory!) { submitDetection( From 686d18f18badd49dbee704c40e7e14730e0325c6 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 18 Dec 2023 14:54:50 -0800 Subject: [PATCH 49/50] Add confirmation, cancel, and notification submission --- .../notifications/resources/notification.ex | 10 +- ui/src/components/DetectionsTable.tsx | 196 +++++++++++++- ui/src/graphql/generated/index.ts | 245 +++++++++++++++++- .../mutations/cancelNotification.graphql | 17 ++ .../notifyConfirmedCandidate.graphql | 2 +- .../getNotificationsForCandidate.graphql | 12 + ui/src/pages/reports/[candidateId].tsx | 2 +- ui/src/pages/reports/index.tsx | 4 +- 8 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 ui/src/graphql/mutations/cancelNotification.graphql create mode 100644 ui/src/graphql/queries/getNotificationsForCandidate.graphql diff --git a/server/lib/orcasite/notifications/resources/notification.ex b/server/lib/orcasite/notifications/resources/notification.ex index fc62e901..f7dc68fe 100644 --- a/server/lib/orcasite/notifications/resources/notification.ex +++ b/server/lib/orcasite/notifications/resources/notification.ex @@ -25,7 +25,7 @@ defmodule Orcasite.Notifications.Notification do constraints one_of: Event.types() end - create_timestamp :inserted_at + create_timestamp :inserted_at, private?: false, writable?: false update_timestamp :updated_at end @@ -62,6 +62,7 @@ defmodule Orcasite.Notifications.Notification do end read :for_candidate do + prepare build(sort: [inserted_at: :desc]) argument :candidate_id, :string, allow_nil?: false argument :event_type, :atom do @@ -105,7 +106,7 @@ defmodule Orcasite.Notifications.Notification do create :notify_confirmed_candidate do description "Create a notification for confirmed candidate (i.e. detection group)" accept [:candidate_id, :message] - argument :candidate_id, :uuid, allow_nil?: false + argument :candidate_id, :string, allow_nil?: false argument :message, :string do description """ @@ -119,7 +120,9 @@ defmodule Orcasite.Notifications.Notification do change set_attribute(:event_type, :confirmed_candidate) change fn changeset, _context -> - candidate_id = Ash.Changeset.get_argument(changeset, :candidate_id) + candidate_id = + Ash.Changeset.get_argument(changeset, :candidate_id) + |> IO.inspect(label: "candidate id") candidate = Orcasite.Radio.Candidate @@ -227,6 +230,7 @@ defmodule Orcasite.Notifications.Notification do mutations do create :notify_confirmed_candidate, :notify_confirmed_candidate + update :cancel_notification, :cancel_notification end end end diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 6d4b3b55..c2d994c5 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -1,21 +1,32 @@ +import { Close } from "@mui/icons-material"; import { Box, Button, Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, Table, TableBody, TableCell, TableHead, TableRow, + TextareaAutosize, + Typography, } from "@mui/material"; import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import { Candidate, Detection, Feed, + useCancelNotificationMutation, useCandidateQuery, useGetCurrentUserQuery, + useNotificationsForCandidateQuery, useNotifyConfirmedCandidateMutation, useSetDetectionVisibleMutation, } from "@/graphql/generated"; @@ -51,7 +62,16 @@ export default function DetectionsTable({ }, }); - const notifyConfirmedCandidate = useNotifyConfirmedCandidateMutation(); + const { notificationsForCandidate: notifications } = + useNotificationsForCandidateQuery({ candidateId: candidate.id }).data ?? {}; + + const cancelNotification = useCancelNotificationMutation({ + onSuccess: () => { + queryClient.invalidateQueries( + useNotificationsForCandidateQuery.getKey({ candidateId: candidate.id }), + ); + }, + }); return ( @@ -135,6 +155,180 @@ export default function DetectionsTable({ ))} + + {currentUser?.moderator && ( + + +

Notifications

+ + + queryClient.invalidateQueries( + useNotificationsForCandidateQuery.getKey({ + candidateId: candidate.id, + }), + ) + } + /> + +
+ {!notifications && No notifications} + {notifications && ( + + + + Event + Status + Created + Actions + + + + {notifications.map((notification, index) => ( + + + {notification.eventType?.toLowerCase()} + + + + + + {formatTimestamp(notification.insertedAt)} + + + {notification.active && ( + + )} + + + ))} + +
+ )} +
+ )}
); } + +function NotificationModal({ + candidateId, + onNotification, +}: { + candidateId: string; + onNotification: () => void; +}) { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(""); + const [confirming, setConfirming] = useState(false); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + setMessage(""); + setConfirming(false); + }; + + const handleChange = (e: React.ChangeEvent) => + setMessage(e.target.value); + + const handleSubmit = () => { + setConfirming(true); + }; + + const handleConfirm = () => { + notifyConfirmedCandidate.mutate({ candidateId, message }); + }; + + const notifyConfirmedCandidate = useNotifyConfirmedCandidateMutation({ + onSuccess: () => { + onNotification(); + handleClose(); + }, + }); + + return ( + <> + + + + + Notify subscribers + + + + + + theme.breakpoints.values.sm }} + > + + + + {confirming ? ( + + + + Are you sure? + + + + ) : ( + <> + + + + )} + + + + ); +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 98aa0a51..f2986f2a 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -50,6 +50,15 @@ export type CancelCandidateNotificationsResult = { result?: Maybe; }; +/** The result of the :cancel_notification mutation */ +export type CancelNotificationResult = { + __typename?: "CancelNotificationResult"; + /** Any errors generated, if the mutation failed */ + errors?: Maybe>>; + /** The successful result of the mutation */ + result?: Maybe; +}; + export type Candidate = { __typename?: "Candidate"; detectionCount?: Maybe; @@ -437,13 +446,92 @@ export type Notification = { active?: Maybe; eventType?: Maybe; id: Scalars["ID"]["output"]; + insertedAt: Scalars["DateTime"]["output"]; meta?: Maybe; }; export type NotificationEventType = "CONFIRMED_CANDIDATE" | "NEW_DETECTION"; +export type NotificationFilterActive = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type NotificationFilterEventType = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type NotificationFilterId = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type NotificationFilterInput = { + active?: InputMaybe; + and?: InputMaybe>; + eventType?: InputMaybe; + id?: InputMaybe; + insertedAt?: InputMaybe; + meta?: InputMaybe; + not?: InputMaybe>; + or?: InputMaybe>; +}; + +export type NotificationFilterInsertedAt = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type NotificationFilterMeta = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type NotificationSortField = + | "ACTIVE" + | "EVENT_TYPE" + | "ID" + | "INSERTED_AT" + | "META"; + +export type NotificationSortInput = { + field: NotificationSortField; + order?: InputMaybe; +}; + export type NotifyConfirmedCandidateInput = { - candidateId: Scalars["ID"]["input"]; + candidateId: Scalars["String"]["input"]; /** * What primary message subscribers will get (e.g. 'Southern Resident Killer Whales calls * and clicks can be heard at Orcasound Lab!') @@ -528,6 +616,7 @@ export type RequestPasswordResetInput = { export type RootMutationType = { __typename?: "RootMutationType"; cancelCandidateNotifications?: Maybe; + cancelNotification?: Maybe; /** Create a notification for confirmed candidate (i.e. detection group) */ notifyConfirmedCandidate?: Maybe; /** Register a new user with a username and password. */ @@ -545,6 +634,10 @@ export type RootMutationTypeCancelCandidateNotificationsArgs = { input?: InputMaybe; }; +export type RootMutationTypeCancelNotificationArgs = { + id?: InputMaybe; +}; + export type RootMutationTypeNotifyConfirmedCandidateArgs = { input?: InputMaybe; }; @@ -583,6 +676,7 @@ export type RootQueryType = { detections?: Maybe; feed: Feed; feeds: Array; + notificationsForCandidate: Array; }; export type RootQueryTypeCandidateArgs = { @@ -621,6 +715,14 @@ export type RootQueryTypeFeedsArgs = { sort?: InputMaybe>>; }; +export type RootQueryTypeNotificationsForCandidateArgs = { + active?: InputMaybe; + candidateId: Scalars["String"]["input"]; + eventType?: InputMaybe; + filter?: InputMaybe; + sort?: InputMaybe>>; +}; + export type SetDetectionVisibleInput = { visible?: InputMaybe; }; @@ -780,8 +882,34 @@ export type CancelCandidateNotificationsMutation = { } | null; }; +export type CancelNotificationMutationVariables = Exact<{ + id: Scalars["ID"]["input"]; +}>; + +export type CancelNotificationMutation = { + __typename?: "RootMutationType"; + cancelNotification?: { + __typename?: "CancelNotificationResult"; + result?: { + __typename?: "Notification"; + id: string; + meta?: { [key: string]: any } | null; + active?: boolean | null; + insertedAt: Date; + } | null; + errors?: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + } | null> | null; + } | null; +}; + export type NotifyConfirmedCandidateMutationVariables = Exact<{ - candidateId: Scalars["ID"]["input"]; + candidateId: Scalars["String"]["input"]; message: Scalars["String"]["input"]; }>; @@ -1022,6 +1150,23 @@ export type FeedQuery = { }; }; +export type NotificationsForCandidateQueryVariables = Exact<{ + candidateId: Scalars["String"]["input"]; + eventType?: InputMaybe; +}>; + +export type NotificationsForCandidateQuery = { + __typename?: "RootQueryType"; + notificationsForCandidate: Array<{ + __typename?: "Notification"; + id: string; + eventType?: NotificationEventType | null; + meta?: { [key: string]: any } | null; + active?: boolean | null; + insertedAt: Date; + }>; +}; + export type CandidatesQueryVariables = Exact<{ filter?: InputMaybe; limit?: InputMaybe; @@ -1136,8 +1281,63 @@ useCancelCandidateNotificationsMutation.fetcher = ( CancelCandidateNotificationsMutation, CancelCandidateNotificationsMutationVariables >(CancelCandidateNotificationsDocument, variables, options); +export const CancelNotificationDocument = ` + mutation cancelNotification($id: ID!) { + cancelNotification(id: $id) { + result { + id + meta + active + insertedAt + } + errors { + code + fields + message + shortMessage + vars + } + } +} + `; +export const useCancelNotificationMutation = < + TError = unknown, + TContext = unknown, +>( + options?: UseMutationOptions< + CancelNotificationMutation, + TError, + CancelNotificationMutationVariables, + TContext + >, +) => + useMutation< + CancelNotificationMutation, + TError, + CancelNotificationMutationVariables, + TContext + >( + ["cancelNotification"], + (variables?: CancelNotificationMutationVariables) => + fetcher( + CancelNotificationDocument, + variables, + )(), + options, + ); +useCancelNotificationMutation.getKey = () => ["cancelNotification"]; + +useCancelNotificationMutation.fetcher = ( + variables: CancelNotificationMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + CancelNotificationDocument, + variables, + options, + ); export const NotifyConfirmedCandidateDocument = ` - mutation notifyConfirmedCandidate($candidateId: ID!, $message: String!) { + mutation notifyConfirmedCandidate($candidateId: String!, $message: String!) { notifyConfirmedCandidate(input: {candidateId: $candidateId, message: $message}) { result { id @@ -1663,6 +1863,45 @@ useFeedQuery.fetcher = ( variables: FeedQueryVariables, options?: RequestInit["headers"], ) => fetcher(FeedDocument, variables, options); +export const NotificationsForCandidateDocument = ` + query notificationsForCandidate($candidateId: String!, $eventType: NotificationEventType) { + notificationsForCandidate(candidateId: $candidateId, eventType: $eventType) { + id + eventType + meta + active + insertedAt + } +} + `; +export const useNotificationsForCandidateQuery = < + TData = NotificationsForCandidateQuery, + TError = unknown, +>( + variables: NotificationsForCandidateQueryVariables, + options?: UseQueryOptions, +) => + useQuery( + ["notificationsForCandidate", variables], + fetcher< + NotificationsForCandidateQuery, + NotificationsForCandidateQueryVariables + >(NotificationsForCandidateDocument, variables), + options, + ); +useNotificationsForCandidateQuery.document = NotificationsForCandidateDocument; + +useNotificationsForCandidateQuery.getKey = ( + variables: NotificationsForCandidateQueryVariables, +) => ["notificationsForCandidate", variables]; +useNotificationsForCandidateQuery.fetcher = ( + variables: NotificationsForCandidateQueryVariables, + options?: RequestInit["headers"], +) => + fetcher< + NotificationsForCandidateQuery, + NotificationsForCandidateQueryVariables + >(NotificationsForCandidateDocument, variables, options); export const CandidatesDocument = ` query candidates($filter: CandidateFilterInput, $limit: Int, $offset: Int, $sort: [CandidateSortInput]) { candidates(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { diff --git a/ui/src/graphql/mutations/cancelNotification.graphql b/ui/src/graphql/mutations/cancelNotification.graphql new file mode 100644 index 00000000..deb64bc8 --- /dev/null +++ b/ui/src/graphql/mutations/cancelNotification.graphql @@ -0,0 +1,17 @@ +mutation cancelNotification($id: ID!) { + cancelNotification(id: $id) { + result { + id + meta + active + insertedAt + } + errors { + code + fields + message + shortMessage + vars + } + } +} diff --git a/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql b/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql index da551061..44229bf1 100644 --- a/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql +++ b/ui/src/graphql/mutations/notifyConfirmedCandidate.graphql @@ -1,4 +1,4 @@ -mutation notifyConfirmedCandidate($candidateId: ID!, $message: String!) { +mutation notifyConfirmedCandidate($candidateId: String!, $message: String!) { notifyConfirmedCandidate( input: { candidateId: $candidateId, message: $message } ) { diff --git a/ui/src/graphql/queries/getNotificationsForCandidate.graphql b/ui/src/graphql/queries/getNotificationsForCandidate.graphql new file mode 100644 index 00000000..9149a544 --- /dev/null +++ b/ui/src/graphql/queries/getNotificationsForCandidate.graphql @@ -0,0 +1,12 @@ +query notificationsForCandidate( + $candidateId: String! + $eventType: NotificationEventType +) { + notificationsForCandidate(candidateId: $candidateId, eventType: $eventType) { + id + eventType + meta + active + insertedAt + } +} diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index 97299284..30da84a6 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -34,7 +34,7 @@ const CandidatePage: NextPageWithLayout = () => { {candidate?.id} - + diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index cde969c5..ac4d1e6d 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -94,7 +94,7 @@ const DetectionsPage: NextPageWithLayout = () => {

Reports

- + { @@ -104,7 +104,7 @@ const DetectionsPage: NextPageWithLayout = () => { className="p-4" > - + From fa336a650a19ee77c763a3a7e9929410d77e090d Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 3 Jan 2024 11:16:02 -0800 Subject: [PATCH 50/50] Change invalidateQuery to refetch when creating new notification --- ui/src/components/DetectionsTable.tsx | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 63346797..a9d7db24 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -16,7 +16,6 @@ import { TextareaAutosize, Typography, } from "@mui/material"; -import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { @@ -53,20 +52,19 @@ export default function DetectionsTable({ const { currentUser } = useGetCurrentUserQuery().data ?? {}; - const queryClient = useQueryClient(); - const setDetectionVisible = useSetDetectionVisibleMutation({ onSuccess: onDetectionUpdate, }); + const notificationsQuery = useNotificationsForCandidateQuery({ + candidateId: candidate.id, + }); const { notificationsForCandidate: notifications } = - useNotificationsForCandidateQuery({ candidateId: candidate.id }).data ?? {}; + notificationsQuery.data ?? {}; const cancelNotification = useCancelNotificationMutation({ onSuccess: () => { - queryClient.invalidateQueries( - useNotificationsForCandidateQuery.getKey({ candidateId: candidate.id }), - ); + notificationsQuery.refetch(); }, }); @@ -164,13 +162,7 @@ export default function DetectionsTable({ - queryClient.invalidateQueries( - useNotificationsForCandidateQuery.getKey({ - candidateId: candidate.id, - }), - ) - } + onNotification={() => notificationsQuery.refetch()} />