diff --git a/web/apps/admin/src/layout/auth.tsx b/web/apps/admin/src/layout/auth.tsx index c318cc139..524eab9dd 100644 --- a/web/apps/admin/src/layout/auth.tsx +++ b/web/apps/admin/src/layout/auth.tsx @@ -6,7 +6,7 @@ import { themeConfig } from "~/configs/theme"; // TODO: remove frontier client dependency from auth pages like login, signup etc in SDK export default function AuthLayout() { return ( - + ); diff --git a/web/apps/client-demo/package.json b/web/apps/client-demo/package.json index a76cb3f60..56a8c4996 100644 --- a/web/apps/client-demo/package.json +++ b/web/apps/client-demo/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@radix-ui/react-icons": "^1.3.0", - "@raystack/apsara": "0.56.6", + "@raystack/apsara": "1.0.0-rc.2", "@raystack/frontier": "workspace:^", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/web/apps/client-demo/src/App.tsx b/web/apps/client-demo/src/App.tsx index 0d3105654..d57f97a84 100644 --- a/web/apps/client-demo/src/App.tsx +++ b/web/apps/client-demo/src/App.tsx @@ -1,9 +1,11 @@ import config from '@/config/frontier'; import AuthContextProvider from '@/contexts/auth/provider'; -import { FrontierProvider } from '@raystack/frontier/react'; +import { FrontierProvider } from '@raystack/frontier/client'; +import { Toast } from '@raystack/apsara'; import Router from './Router'; import { v4 as uuid } from 'uuid'; import './styles.css'; +import '@raystack/apsara/style.css' import '@raystack/apsara/normalize.css'; const customHeaders = { @@ -16,9 +18,11 @@ function App() { config={config} customHeaders={customHeaders} > - - - + + + + + ); } diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 97b007d6b..425ed6ca1 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -6,7 +6,6 @@ import Callback from './pages/Callback'; import MagiclinkVerify from './pages/MagiclinkVerify'; import Subscribe from './pages/Subscribe'; import Updates from './pages/Updates'; -import Organization from './pages/Organization'; import Settings from './pages/Settings'; import General from './pages/settings/General'; import Preferences from './pages/settings/Preferences'; @@ -37,7 +36,6 @@ function Router() { } /> } /> } /> - } /> }> } /> } /> diff --git a/web/apps/client-demo/src/config/frontier.ts b/web/apps/client-demo/src/config/frontier.ts index 3d5a7c61c..dda949eb3 100644 --- a/web/apps/client-demo/src/config/frontier.ts +++ b/web/apps/client-demo/src/config/frontier.ts @@ -1,4 +1,4 @@ -import type { FrontierClientOptions } from '@raystack/frontier/react'; +import type { FrontierClientOptions } from '@raystack/frontier/client'; const config: FrontierClientOptions = { endpoint: '/api', diff --git a/web/apps/client-demo/src/contexts/auth/provider.tsx b/web/apps/client-demo/src/contexts/auth/provider.tsx index faef55dde..b2eef3592 100644 --- a/web/apps/client-demo/src/contexts/auth/provider.tsx +++ b/web/apps/client-demo/src/contexts/auth/provider.tsx @@ -2,7 +2,7 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; import AuthContext from '.'; -import { useFrontier } from '@raystack/frontier/react'; +import { useFrontier } from '@raystack/frontier/client'; const AuthContextProvider: React.FC> = ({ children }) => { const [isAuthorized, setIsAuthorized] = useState(false); diff --git a/web/apps/client-demo/src/pages/Home.tsx b/web/apps/client-demo/src/pages/Home.tsx index 620011f00..3a128d2fe 100644 --- a/web/apps/client-demo/src/pages/Home.tsx +++ b/web/apps/client-demo/src/pages/Home.tsx @@ -3,24 +3,26 @@ import { Avatar, Button, DataTable, - DropdownMenu, + Menu, Flex, Navbar, Text, useTheme, getAvatarColor, + toastManager, + IconButton, + Separator, type DataTableColumnDef, } from '@raystack/apsara'; -import { useFrontier, useTerminology } from '@raystack/frontier/react'; +import { useFrontier, useTerminology } from '@raystack/frontier/client'; import { useMutation, useQuery, FrontierServiceQueries, useQueryClient, } from '@raystack/frontier/hooks'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useContext, useEffect, useMemo, useCallback, useState, type MouseEvent } from 'react'; -import { toast, IconButton, Separator } from '@raystack/apsara'; import { DesktopIcon, MagnifyingGlassIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons'; type OrgRow = { @@ -62,9 +64,7 @@ function tsToMs(ts?: { seconds?: bigint; nanos?: number }): number { function getColumns( onAccept: (row: OrgRow) => void, - onOpen: (row: OrgRow, e: MouseEvent) => void, - acceptingId: string | null, - navigate: (path: string) => void + acceptingId: string | null ): DataTableColumnDef[] { return [ { @@ -135,31 +135,15 @@ function getColumns( } if (status === 'joined') { return ( - - - - + ); } return null; @@ -253,11 +237,15 @@ export default function Home() { setAcceptingId(row.invitationId!); try { await acceptInvitation({ id: row.invitationId!, orgId: row.orgId }); - toast.success('Invitation accepted'); + toastManager.add({ title: 'Invitation accepted', type: 'success' }); queryClient.invalidateQueries(); } catch (err) { const message = err instanceof Error ? err.message : 'Something went wrong'; - toast.error(`Failed to accept: ${message}`); + toastManager.add({ + title: 'Failed to accept', + description: message, + type: 'error' + }); } finally { setAcceptingId(null); } @@ -265,19 +253,7 @@ export default function Home() { [acceptInvitation, queryClient], ); - const handleOpen = useCallback( - (row: OrgRow, e: MouseEvent) => { - const path = `/organizations/${row.orgId}`; - if (e.metaKey || e.ctrlKey) { - window.open(path, '_blank'); - } else { - navigate(path); - } - }, - [navigate], - ); - - const columns = useMemo(() => getColumns(handleAccept, handleOpen, acceptingId, navigate), [handleAccept, handleOpen, acceptingId, navigate]); + const columns = useMemo(() => getColumns(handleAccept, acceptingId), [handleAccept, acceptingId]); async function logout() { try { @@ -349,62 +325,66 @@ export default function Home() { )} - - - - {activeTheme === 'system' ? ( - - ) : activeTheme === 'dark' ? ( - - ) : ( - - )} - - - + + + {activeTheme === 'system' ? ( + + ) : activeTheme === 'dark' ? ( + + ) : ( + + )} + + } + /> + {themeOptions.map((item) => ( - setTheme(item.key)} disabled={activeTheme === item.key} data-test-id={item.testId} > {item.icon} {item.label} - + ))} - - + + - - - - - - + + + + + } + /> + + {user?.email} - - + Logout - - - + + + diff --git a/web/apps/client-demo/src/pages/Login.tsx b/web/apps/client-demo/src/pages/Login.tsx index 20cbf07bd..d3f23e524 100644 --- a/web/apps/client-demo/src/pages/Login.tsx +++ b/web/apps/client-demo/src/pages/Login.tsx @@ -1,6 +1,6 @@ import useAuthRedirect from '@/hooks/useAuthRedirect'; import { Flex } from '@raystack/apsara'; -import { SignIn } from '@raystack/frontier/react'; +import { SignInView } from '@raystack/frontier/client'; export default function Login() { useAuthRedirect(); @@ -10,7 +10,7 @@ export default function Login() { align="center" style={{ height: '100vh', width: '100vw' }} > - + ); } diff --git a/web/apps/client-demo/src/pages/MagiclinkVerify.tsx b/web/apps/client-demo/src/pages/MagiclinkVerify.tsx index 60a12c3e1..5a3104ba6 100644 --- a/web/apps/client-demo/src/pages/MagiclinkVerify.tsx +++ b/web/apps/client-demo/src/pages/MagiclinkVerify.tsx @@ -1,6 +1,5 @@ import { Flex } from '@raystack/apsara'; -import { MagicLinkVerify } from '@raystack/frontier/react'; -import React from 'react'; +import { MagicLinkVerifyView } from '@raystack/frontier/client'; export default function MagiclinkVerify() { return ( @@ -9,7 +8,7 @@ export default function MagiclinkVerify() { align="center" style={{ height: '100vh', width: '100vw' }} > - + ); } diff --git a/web/apps/client-demo/src/pages/Organization.tsx b/web/apps/client-demo/src/pages/Organization.tsx deleted file mode 100644 index 1436ef167..000000000 --- a/web/apps/client-demo/src/pages/Organization.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Window, OrganizationProfile } from '@raystack/frontier/react'; -import { useParams } from 'react-router-dom'; - -export default function Organization() { - const { orgId } = useParams<{ orgId: string }>(); - - return orgId ? ( - {}}> - { - window.location.href = '/login'; - }} - /> - - ) : null; -} diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index fcd910111..132bf062b 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { Flex, Sidebar, Text } from '@raystack/apsara'; import { Outlet, useParams, useLocation, Navigate, Link } from 'react-router-dom'; -import { useFrontier } from '@raystack/frontier/react'; +import { useFrontier } from '@raystack/frontier/client'; const NAV_ITEMS = [ { label: 'General', path: 'general' }, diff --git a/web/apps/client-demo/src/pages/Signup.tsx b/web/apps/client-demo/src/pages/Signup.tsx index b9aff67ca..2d61bb49e 100644 --- a/web/apps/client-demo/src/pages/Signup.tsx +++ b/web/apps/client-demo/src/pages/Signup.tsx @@ -1,6 +1,6 @@ import useAuthRedirect from '@/hooks/useAuthRedirect'; import { Flex } from '@raystack/apsara'; -import { SignUp } from '@raystack/frontier/react'; +import { SignUpView } from '@raystack/frontier/client'; export default function Signup() { useAuthRedirect(); @@ -11,7 +11,7 @@ export default function Signup() { align="center" style={{ height: '100vh', width: '100vw' }} > - + ); } diff --git a/web/apps/client-demo/src/pages/Subscribe.tsx b/web/apps/client-demo/src/pages/Subscribe.tsx index 05a609b2e..80cdff313 100644 --- a/web/apps/client-demo/src/pages/Subscribe.tsx +++ b/web/apps/client-demo/src/pages/Subscribe.tsx @@ -1,6 +1,5 @@ import { Flex } from '@raystack/apsara'; -// import { Subscribe } from '@raystack/frontier/react'; -import React from 'react'; +import { SubscribeView } from '@raystack/frontier/client'; export default function Subscribe() { return ( @@ -10,9 +9,7 @@ export default function Subscribe() { align="center" style={{ width: '100vw', height: '95vh' }} > - {/* console.log(JSON.stringify(data))} - /> */} + ); } diff --git a/web/apps/client-demo/src/pages/Updates.tsx b/web/apps/client-demo/src/pages/Updates.tsx index e38b5f2b1..fae8a4ece 100644 --- a/web/apps/client-demo/src/pages/Updates.tsx +++ b/web/apps/client-demo/src/pages/Updates.tsx @@ -1,6 +1,5 @@ import { Flex } from '@raystack/apsara'; -// import { Updates } from '@raystack/frontier/react'; -import React from 'react'; +import { UpdatesView } from '@raystack/frontier/client'; export default function Updates() { return ( @@ -9,7 +8,7 @@ export default function Updates() { align="center" style={{ height: '100vh', width: '100vw' }} > - {/* alert(JSON.stringify(data))} /> */} + ); } diff --git a/web/apps/client-demo/src/pages/settings/Billing.tsx b/web/apps/client-demo/src/pages/settings/Billing.tsx index bb32fa610..5c8a3b8e5 100644 --- a/web/apps/client-demo/src/pages/settings/Billing.tsx +++ b/web/apps/client-demo/src/pages/settings/Billing.tsx @@ -1,4 +1,4 @@ -import { BillingView } from '@raystack/frontier/react'; +import { BillingView } from '@raystack/frontier/client'; import { useNavigate, useParams } from 'react-router-dom'; export default function Billing() { diff --git a/web/apps/client-demo/src/pages/settings/General.tsx b/web/apps/client-demo/src/pages/settings/General.tsx index 3a1d6ba6d..5fc6ebd9a 100644 --- a/web/apps/client-demo/src/pages/settings/General.tsx +++ b/web/apps/client-demo/src/pages/settings/General.tsx @@ -1,4 +1,4 @@ -import { GeneralView } from '@raystack/frontier/react'; +import { GeneralView } from '@raystack/frontier/client'; import { useNavigate } from 'react-router-dom'; export default function General() { diff --git a/web/apps/client-demo/src/pages/settings/Members.tsx b/web/apps/client-demo/src/pages/settings/Members.tsx index 1fe4cfe98..88084cc04 100644 --- a/web/apps/client-demo/src/pages/settings/Members.tsx +++ b/web/apps/client-demo/src/pages/settings/Members.tsx @@ -1,4 +1,4 @@ -import { MembersView } from '@raystack/frontier/react'; +import { MembersView } from '@raystack/frontier/client'; export default function Members() { return ; diff --git a/web/apps/client-demo/src/pages/settings/PatDetails.tsx b/web/apps/client-demo/src/pages/settings/PatDetails.tsx index 51a2073ca..7d268f141 100644 --- a/web/apps/client-demo/src/pages/settings/PatDetails.tsx +++ b/web/apps/client-demo/src/pages/settings/PatDetails.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { PATDetailsView } from '@raystack/frontier/react'; +import { PATDetailsView } from '@raystack/frontier/client'; export default function PatDetails() { const { orgId, patId } = useParams<{ orgId: string; patId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Pats.tsx b/web/apps/client-demo/src/pages/settings/Pats.tsx index 1848ef878..89527404e 100644 --- a/web/apps/client-demo/src/pages/settings/Pats.tsx +++ b/web/apps/client-demo/src/pages/settings/Pats.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { PatsView } from '@raystack/frontier/react'; +import { PatsView } from '@raystack/frontier/client'; export default function Pats() { const { orgId } = useParams<{ orgId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Plans.tsx b/web/apps/client-demo/src/pages/settings/Plans.tsx index 50ab2e9ab..d3c0a4dce 100644 --- a/web/apps/client-demo/src/pages/settings/Plans.tsx +++ b/web/apps/client-demo/src/pages/settings/Plans.tsx @@ -1,4 +1,4 @@ -import { PlansView } from '@raystack/frontier/react'; +import { PlansView } from '@raystack/frontier/client'; export default function Plans() { return ; diff --git a/web/apps/client-demo/src/pages/settings/Preferences.tsx b/web/apps/client-demo/src/pages/settings/Preferences.tsx index 121e95235..5e8b1e770 100644 --- a/web/apps/client-demo/src/pages/settings/Preferences.tsx +++ b/web/apps/client-demo/src/pages/settings/Preferences.tsx @@ -1,4 +1,4 @@ -import { PreferencesView } from '@raystack/frontier/react'; +import { PreferencesView } from '@raystack/frontier/client'; export default function Preferences() { return ; diff --git a/web/apps/client-demo/src/pages/settings/Profile.tsx b/web/apps/client-demo/src/pages/settings/Profile.tsx index 37cc67ad4..636679c78 100644 --- a/web/apps/client-demo/src/pages/settings/Profile.tsx +++ b/web/apps/client-demo/src/pages/settings/Profile.tsx @@ -1,4 +1,4 @@ -import { ProfileView } from '@raystack/frontier/react'; +import { ProfileView } from '@raystack/frontier/client'; export default function Profile() { return ; diff --git a/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx b/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx index 4b949caa3..622c8c33b 100644 --- a/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx +++ b/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { ProjectDetailsView } from '@raystack/frontier/react'; +import { ProjectDetailsView } from '@raystack/frontier/client'; export default function ProjectDetails() { const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Projects.tsx b/web/apps/client-demo/src/pages/settings/Projects.tsx index 85147975a..c8ed4b9ba 100644 --- a/web/apps/client-demo/src/pages/settings/Projects.tsx +++ b/web/apps/client-demo/src/pages/settings/Projects.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { ProjectsView } from '@raystack/frontier/react'; +import { ProjectsView } from '@raystack/frontier/client'; export default function Projects() { const { orgId } = useParams<{ orgId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Security.tsx b/web/apps/client-demo/src/pages/settings/Security.tsx index d9c3b4577..27a518a13 100644 --- a/web/apps/client-demo/src/pages/settings/Security.tsx +++ b/web/apps/client-demo/src/pages/settings/Security.tsx @@ -1,4 +1,4 @@ -import { SecurityView } from '@raystack/frontier/react'; +import { SecurityView } from '@raystack/frontier/client'; export default function Security() { return ; diff --git a/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx b/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx index d0d20eb98..9c18a768b 100644 --- a/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx +++ b/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { ServiceAccountDetailsView } from '@raystack/frontier/react'; +import { ServiceAccountDetailsView } from '@raystack/frontier/client'; export default function ServiceAccountDetails() { const { orgId, serviceAccountId } = useParams<{ diff --git a/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx b/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx index 7a868a4e8..cd97ec9c6 100644 --- a/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx +++ b/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { ServiceAccountsView } from '@raystack/frontier/react'; +import { ServiceAccountsView } from '@raystack/frontier/client'; export default function ServiceAccounts() { const { orgId } = useParams<{ orgId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Sessions.tsx b/web/apps/client-demo/src/pages/settings/Sessions.tsx index dce5c7075..31a47a981 100644 --- a/web/apps/client-demo/src/pages/settings/Sessions.tsx +++ b/web/apps/client-demo/src/pages/settings/Sessions.tsx @@ -1,4 +1,4 @@ -import { SessionsView } from '@raystack/frontier/react'; +import { SessionsView } from '@raystack/frontier/client'; export default function Sessions() { return { diff --git a/web/apps/client-demo/src/pages/settings/TeamDetails.tsx b/web/apps/client-demo/src/pages/settings/TeamDetails.tsx index 926eb6e7e..38cdd0acc 100644 --- a/web/apps/client-demo/src/pages/settings/TeamDetails.tsx +++ b/web/apps/client-demo/src/pages/settings/TeamDetails.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { TeamDetailsView } from '@raystack/frontier/react'; +import { TeamDetailsView } from '@raystack/frontier/client'; export default function TeamDetails() { const { orgId, teamId } = useParams<{ orgId: string; teamId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Teams.tsx b/web/apps/client-demo/src/pages/settings/Teams.tsx index b9c1f4226..45c8882f4 100644 --- a/web/apps/client-demo/src/pages/settings/Teams.tsx +++ b/web/apps/client-demo/src/pages/settings/Teams.tsx @@ -1,5 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { TeamsView } from '@raystack/frontier/react'; +import { TeamsView } from '@raystack/frontier/client'; export default function Teams() { const { orgId } = useParams<{ orgId: string }>(); diff --git a/web/apps/client-demo/src/pages/settings/Tokens.tsx b/web/apps/client-demo/src/pages/settings/Tokens.tsx index bbe743570..05234ec94 100644 --- a/web/apps/client-demo/src/pages/settings/Tokens.tsx +++ b/web/apps/client-demo/src/pages/settings/Tokens.tsx @@ -1,4 +1,4 @@ -import { TokensView } from '@raystack/frontier/react'; +import { TokensView } from '@raystack/frontier/client'; export default function Tokens() { return ; diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 08035ef5f..e519f28e9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -164,8 +164,8 @@ importers: specifier: ^1.3.0 version: 1.3.2(react@19.2.4) '@raystack/apsara': - specifier: 0.56.6 - version: 0.56.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 1.0.0-rc.2 + version: 1.0.0-rc.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@raystack/frontier': specifier: workspace:^ version: link:../../sdk @@ -7295,6 +7295,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: diff --git a/web/sdk/package.json b/web/sdk/package.json index 8a260094c..faacd433a 100644 --- a/web/sdk/package.json +++ b/web/sdk/package.json @@ -51,6 +51,12 @@ "module": "./react/dist/index.mjs", "require": "./react/dist/index.js" }, + "./client": { + "types": "./react/dist/client.d.ts", + "import": "./react/dist/client.mjs", + "module": "./react/dist/client.mjs", + "require": "./react/dist/client.js" + }, "./hooks": { "types": "./hooks/dist/index.d.ts", "import": "./hooks/dist/index.mjs", diff --git a/web/sdk/react/client.ts b/web/sdk/react/client.ts new file mode 100644 index 000000000..a80595bdd --- /dev/null +++ b/web/sdk/react/client.ts @@ -0,0 +1,58 @@ +import '@raystack/apsara-v1/style.css'; +import '@raystack/apsara-v1/normalize.css'; + +export { ImageUpload } from './components/image-upload'; +export { ViewContainer } from './components/view-container'; +export { ViewHeader } from './components/view-header'; +export { AuthContainer } from './components/auth-container'; +export { AuthHeader } from './components/auth-header'; + +export { SignInView } from './views-new/auth/sign-in'; +export { SignUpView } from './views-new/auth/sign-up'; +export { MagicLinkView } from './views-new/auth/magic-link'; +export { MagicLinkVerifyView } from './views-new/auth/magic-link-verify'; +export { SubscribeView } from './views-new/auth/subscribe'; +export { UpdatesView } from './views-new/auth/updates'; + +export { GeneralView } from './views-new/general'; +export { PreferencesView, PreferenceRow } from './views-new/preferences'; +export { ProfileView } from './views-new/profile'; +export { SessionsView } from './views-new/sessions'; +export { MembersView } from './views-new/members'; +export { SecurityView } from './views-new/security'; +export { ProjectsView, ProjectDetailsView } from './views-new/projects'; +export { BillingView } from './views-new/billing'; +export { TokensView } from './views-new/tokens'; +export { TeamsView, TeamDetailsView } from './views-new/teams'; +export { + ServiceAccountsView, + ServiceAccountDetailsView +} from './views-new/service-accounts'; +export { PlansView } from './views-new/plans'; +export { PatsView, PATDetailsView } from './views-new/pat'; +export { CreateOrganizationView } from './views-new/create-organization'; + +export { useFrontier } from './contexts/FrontierContext'; +export { FrontierProvider, queryClient } from './contexts/FrontierProvider'; +export { CustomizationProvider } from './contexts/CustomizationContext'; + +export { useTerminology } from './hooks/useTerminology'; +export { useTokens } from './hooks/useTokensV1'; +export { useBillingPermission } from './hooks/useBillingPermission'; +export { useConnectQueryPolling } from './hooks/useConnectQueryPolling'; +export { usePreferences } from './hooks/usePreferences'; + +export type { + FrontierClientOptions, + FrontierClientBillingOptions, + FrontierClientCustomizationOptions +} from '../shared/types'; + +export { PREFERENCE_OPTIONS } from './utils/constants'; + +export { + timestampToDate, + timestampToDayjs, + isNullTimestamp +} from '../utils/timestamp'; +export type { TimeStamp } from '../utils/timestamp'; diff --git a/web/sdk/react/components/auth-container/auth-container.module.css b/web/sdk/react/components/auth-container/auth-container.module.css new file mode 100644 index 000000000..1d0201a8a --- /dev/null +++ b/web/sdk/react/components/auth-container/auth-container.module.css @@ -0,0 +1,6 @@ +.container { + min-width: 220px; + max-width: 480px; + width: 100%; + color: var(--rs-color-foreground-base-primary); +} diff --git a/web/sdk/react/components/auth-container/auth-container.tsx b/web/sdk/react/components/auth-container/auth-container.tsx new file mode 100644 index 000000000..81c3e0994 --- /dev/null +++ b/web/sdk/react/components/auth-container/auth-container.tsx @@ -0,0 +1,27 @@ +import { ComponentPropsWithRef, ReactNode } from 'react'; +import { Flex } from '@raystack/apsara-v1'; +import { cx } from 'class-variance-authority'; +import styles from './auth-container.module.css'; + +export type AuthContainerProps = ComponentPropsWithRef<'div'> & { + children?: ReactNode; + className?: string; +}; + +export const AuthContainer = ({ + children, + style, + className +}: AuthContainerProps) => { + return ( + + {children} + + ); +}; diff --git a/web/sdk/react/components/auth-container/index.ts b/web/sdk/react/components/auth-container/index.ts new file mode 100644 index 000000000..442a8ae30 --- /dev/null +++ b/web/sdk/react/components/auth-container/index.ts @@ -0,0 +1,2 @@ +export { AuthContainer } from './auth-container'; +export type { AuthContainerProps } from './auth-container'; diff --git a/web/sdk/react/components/auth-header/auth-header.module.css b/web/sdk/react/components/auth-header/auth-header.module.css new file mode 100644 index 000000000..b5b82a4ac --- /dev/null +++ b/web/sdk/react/components/auth-header/auth-header.module.css @@ -0,0 +1,11 @@ +.container { + min-width: 220px; + max-width: 100%; + color: var(--rs-color-foreground-base-primary); +} + +.logo { + border-radius: var(--rs-space-3); + width: 80px; + height: 80px; +} diff --git a/web/sdk/react/components/auth-header/auth-header.tsx b/web/sdk/react/components/auth-header/auth-header.tsx new file mode 100644 index 000000000..a1c3156a6 --- /dev/null +++ b/web/sdk/react/components/auth-header/auth-header.tsx @@ -0,0 +1,28 @@ +import { ComponentPropsWithRef, ReactNode } from 'react'; +import { Flex, Headline } from '@raystack/apsara-v1'; +import logo from '~/react/assets/logo.png'; +import styles from './auth-header.module.css'; + +const defaultLogo = ( + // eslint-disable-next-line @next/next/no-img-element + logo +); + +export type AuthHeaderProps = ComponentPropsWithRef<'div'> & { + title?: string; + logo?: ReactNode; +}; + +export const AuthHeader = ({ title, logo }: AuthHeaderProps) => { + return ( + +
{logo ? logo : defaultLogo}
+ {title} +
+ ); +}; diff --git a/web/sdk/react/components/auth-header/index.ts b/web/sdk/react/components/auth-header/index.ts new file mode 100644 index 000000000..4e474449d --- /dev/null +++ b/web/sdk/react/components/auth-header/index.ts @@ -0,0 +1,2 @@ +export { AuthHeader } from './auth-header'; +export type { AuthHeaderProps } from './auth-header'; diff --git a/web/sdk/react/components/auth-oidc-button/auth-oidc-button.module.css b/web/sdk/react/components/auth-oidc-button/auth-oidc-button.module.css new file mode 100644 index 000000000..85c7d8fa2 --- /dev/null +++ b/web/sdk/react/components/auth-oidc-button/auth-oidc-button.module.css @@ -0,0 +1,7 @@ +.button { + width: 100%; +} + +.logo { + margin-right: var(--rs-space-2); +} diff --git a/web/sdk/react/components/auth-oidc-button/auth-oidc-button.tsx b/web/sdk/react/components/auth-oidc-button/auth-oidc-button.tsx new file mode 100644 index 000000000..91be9f26a --- /dev/null +++ b/web/sdk/react/components/auth-oidc-button/auth-oidc-button.tsx @@ -0,0 +1,31 @@ +import { Button, Text } from '@raystack/apsara-v1'; +import { HTMLProps } from 'react'; +import GoogleLogo from '~/react/assets/logos/google-logo.svg'; +import { capitalize } from '~/utils'; +import styles from './auth-oidc-button.module.css'; + +const oidcLogoMap = new Map([['google', GoogleLogo]]); + +export type AuthOIDCButtonProps = HTMLProps & { + provider: string; +}; + +export const AuthOIDCButton = ({ onClick, provider }: AuthOIDCButtonProps) => ( + +); diff --git a/web/sdk/react/components/auth-oidc-button/index.ts b/web/sdk/react/components/auth-oidc-button/index.ts new file mode 100644 index 000000000..777bbdc46 --- /dev/null +++ b/web/sdk/react/components/auth-oidc-button/index.ts @@ -0,0 +1,2 @@ +export { AuthOIDCButton } from './auth-oidc-button'; +export type { AuthOIDCButtonProps } from './auth-oidc-button'; diff --git a/web/sdk/react/contexts/FrontierProvider.tsx b/web/sdk/react/contexts/FrontierProvider.tsx index ceb7d3157..d588f764f 100644 --- a/web/sdk/react/contexts/FrontierProvider.tsx +++ b/web/sdk/react/contexts/FrontierProvider.tsx @@ -1,4 +1,4 @@ -import { ThemeProvider } from '@raystack/apsara'; +import { ThemeProvider, Toast } from '@raystack/apsara-v1'; import { FrontierProviderProps } from '../../shared/types'; import { FrontierContextProvider } from './FrontierContext'; import { CustomizationProvider } from './CustomizationContext'; @@ -8,7 +8,6 @@ import { TransportProvider } from '@connectrpc/connect-query'; import { createConnectTransport } from '@connectrpc/connect-web'; import { ComponentType, ReactNode, useMemo } from 'react'; import { createFetchWithCreds } from '../utils/fetch'; -import { Toast } from '@raystack/apsara-v1'; export const multipleFrontierProvidersError = "Frontier: You've added multiple components in your React component tree. Wrap your components in a single ."; diff --git a/web/sdk/react/hooks/useSessionsV1.ts b/web/sdk/react/hooks/useSessionsV1.ts new file mode 100644 index 000000000..fe11397d9 --- /dev/null +++ b/web/sdk/react/hooks/useSessionsV1.ts @@ -0,0 +1,116 @@ +import { useMemo } from 'react'; +import { + useQuery, + useMutation, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { toastManager } from '@raystack/apsara-v1'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { timestampToDayjs } from '../../utils/timestamp'; +import { formatLocation } from '../utils'; + +dayjs.extend(relativeTime); + +// Utility function to format error messages based on status code +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && 'status' in error && error.status === 500) { + return 'Something went wrong'; + } + if (error instanceof Error) { + return error.message; + } + return 'Something went wrong'; +}; + +export const formatDeviceDisplay = ( + browser?: string, + operatingSystem?: string +): string => { + const browserName = browser || 'Unknown'; + const osName = operatingSystem || 'Unknown'; + return browserName === 'Unknown' && osName === 'Unknown' + ? 'Unknown browser and OS' + : `${browserName} on ${osName}`; +}; + +export const useSessions = () => { + const queryClient = useQueryClient(); + const transport = useTransport(); + + const { + data: sessionsData, + isLoading, + error + } = useQuery(FrontierServiceQueries.listSessions, {}); + + const formatLastActive = (updatedAt?: any) => { + const d = timestampToDayjs(updatedAt); + return d ? d.fromNow() : 'Unknown'; + }; + + const sessions = useMemo( + () => + (sessionsData?.sessions || []) + .map((session: any) => ({ + id: session.id || '', + browser: session.metadata?.browser || 'Unknown', + operatingSystem: session.metadata?.operatingSystem || 'Unknown', + ipAddress: session.metadata?.ipAddress || 'Unknown', + location: formatLocation(session.metadata?.location), + lastActive: formatLastActive(session.updatedAt), + isCurrent: session.isCurrentSession || false + })) + .sort((a, b) => { + // Current session first, then by last active (most recent first) + if (a.isCurrent && !b.isCurrent) return -1; + if (!a.isCurrent && b.isCurrent) return 1; + return 0; // Keep original order for non-current sessions + }), + [sessionsData?.sessions] + ); + + const { mutate: revokeSession, isPending: isRevokingSession } = useMutation( + FrontierServiceQueries.revokeSession, + { + onSuccess: () => { + // Invalidate and refetch the sessions list + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listSessions, + transport, + input: {}, + cardinality: 'finite' + }) + }); + toastManager.add({ + title: 'Session revoked successfully', + type: 'success' + }); + }, + onError: (error: any) => { + toastManager.add({ + title: 'Failed to revoke session', + description: getErrorMessage(error), + type: 'error' + }); + } + } + ); + + const handleRevokeSession = (sessionId: string, options?: any) => { + revokeSession({ sessionId }, options); + }; + + return { + sessions, + isLoading, + error: error?.message || null, + refetch: () => {}, + revokeSession: handleRevokeSession, + isRevokingSession + }; +}; diff --git a/web/sdk/react/hooks/useTokensV1.ts b/web/sdk/react/hooks/useTokensV1.ts new file mode 100644 index 000000000..f1f3b263f --- /dev/null +++ b/web/sdk/react/hooks/useTokensV1.ts @@ -0,0 +1,54 @@ +import { useMemo, useEffect } from 'react'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { FrontierServiceQueries, GetBillingBalanceRequestSchema } from '@raystack/proton/frontier'; +import { useFrontier } from '../contexts/FrontierContext'; +import { toastManager } from '@raystack/apsara-v1'; + +interface UseTokensReturn { + tokenBalance: bigint; + isTokensLoading: boolean; + fetchTokenBalance: () => Promise; +} + +export const useTokens = (): UseTokensReturn => { + const { billingAccount } = useFrontier(); + + const { + data, + isLoading: isTokensLoading, + error, + refetch + } = useQuery( + FrontierServiceQueries.getBillingBalance, + create(GetBillingBalanceRequestSchema, { + id: billingAccount?.id ?? '' + }), + { + enabled: !!billingAccount?.id, + retry: false + } + ); + + // Handle errors + useEffect(() => { + if (error) { + console.error(error); + toastManager.add({ + title: 'Unable to fetch balance', + type: 'error' + }); + } + }, [error]); + + const tokenBalance = useMemo( + () => BigInt(data?.balance?.amount || '0'), + [data?.balance?.amount] + ); + + return { + tokenBalance, + isTokensLoading, + fetchTokenBalance: refetch + }; +}; diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts index 4c59d0730..cff68faed 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -1,6 +1,4 @@ import '@raystack/apsara/style.css'; -import '@raystack/apsara-v1/style.css'; -import '@raystack/apsara-v1/normalize.css'; export { AvatarUpload } from './components/avatar-upload'; export { Container } from './components/Container'; @@ -27,26 +25,6 @@ export { usePreferences } from './hooks/usePreferences'; export { Layout } from './components/Layout'; export { PageHeader } from './components/common/page-header'; -export { ImageUpload } from './components/image-upload'; -export { ViewContainer } from './components/view-container'; -export { ViewHeader } from './components/view-header'; -export { GeneralView } from './views-new/general'; -export { PreferencesView, PreferenceRow } from './views-new/preferences'; -export { ProfileView } from './views-new/profile'; -export { SessionsView } from './views-new/sessions'; -export { MembersView } from './views-new/members'; -export { SecurityView } from './views-new/security'; -export { ProjectsView, ProjectDetailsView } from './views-new/projects'; -export { BillingView } from './views-new/billing'; -export { TokensView } from './views-new/tokens'; -export { TeamsView, TeamDetailsView } from './views-new/teams'; -export { - ServiceAccountsView, - ServiceAccountDetailsView -} from './views-new/service-accounts'; -export { PlansView } from './views-new/plans'; -export { PatsView, PATDetailsView } from './views-new/pat'; - export type { FrontierClientOptions, FrontierClientBillingOptions, diff --git a/web/sdk/react/views-new/auth/magic-link-verify/index.ts b/web/sdk/react/views-new/auth/magic-link-verify/index.ts new file mode 100644 index 000000000..8700bde81 --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link-verify/index.ts @@ -0,0 +1 @@ +export { MagicLinkVerifyView } from './magic-link-verify-view'; diff --git a/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.module.css b/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.module.css new file mode 100644 index 000000000..f43c2e074 --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.module.css @@ -0,0 +1,27 @@ +.button { + width: 100%; +} + +.form { + width: 80%; + display: flex; + flex-direction: column; + gap: var(--rs-space-8); + letter-spacing: 0.4px; +} + +.otpInputContainer { + position: relative; +} + +.error { + color: var(--rs-color-foreground-danger-primary); + position: absolute; + top: 100%; + left: 0; + right: 0; +} + +.textFieldCode { + text-align: center; +} diff --git a/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.tsx b/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.tsx new file mode 100644 index 000000000..923724896 --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link-verify/magic-link-verify-view.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { Button, Text, Link, Flex, InputField } from '@raystack/apsara-v1'; +import { + ChangeEvent, + ComponentPropsWithRef, + FormEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState +} from 'react'; +import { useMutation } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { + AuthContainer, + type AuthContainerProps +} from '~/react/components/auth-container'; +import { AuthHeader } from '~/react/components/auth-header'; +import styles from './magic-link-verify-view.module.css'; + +export type MagicLinkVerifyViewProps = ComponentPropsWithRef<'div'> & + AuthContainerProps & { + logo?: ReactNode; + title?: string; + redirectURL?: string; + }; + +export const MagicLinkVerifyView = ({ + logo, + title = 'Check your email', + redirectURL, + ...props +}: MagicLinkVerifyViewProps) => { + const { config } = useFrontier(); + + const { mutateAsync: authCallback, isPending } = useMutation( + FrontierServiceQueries.authCallback + ); + const [emailParam, setEmailParam] = useState(''); + const [stateParam, setStateParam] = useState(''); + const [otp, setOTP] = useState(''); + const [submitError, setSubmitError] = useState(''); + const isButtonDisabledRef = useRef(true); + + const handleOTPChange = (event: ChangeEvent) => { + const { value } = event.target; + isButtonDisabledRef.current = value.length === 0; + if (submitError.length > 0) setSubmitError(''); + setOTP(value); + }; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const emailParam = params.get('email'); + const stateParam = params.get('state'); + + emailParam && setEmailParam(emailParam); + stateParam && setStateParam(stateParam); + }, []); + + const OTPVerifyHandler = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + try { + await authCallback({ + strategyName: 'mailotp', + code: otp, + state: stateParam + }); + + const destination = redirectURL ?? window.location.origin; + window.location.replace(destination); + } catch (error) { + console.log(error); + isButtonDisabledRef.current = true; + setSubmitError('Please enter a valid OTP'); + } + }, + [otp, stateParam, authCallback, redirectURL] + ); + + return ( + + + + {emailParam && ( + + We have sent an OTP. Please check your inbox at + {emailParam} + + )} + + +
+ + + + + {submitError && String(submitError)} + + + + +
+ + + Back to login + +
+ ); +}; diff --git a/web/sdk/react/views-new/auth/magic-link/index.ts b/web/sdk/react/views-new/auth/magic-link/index.ts new file mode 100644 index 000000000..ad01d21b7 --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link/index.ts @@ -0,0 +1 @@ +export { MagicLinkView } from './magic-link-view'; diff --git a/web/sdk/react/views-new/auth/magic-link/magic-link-view.module.css b/web/sdk/react/views-new/auth/magic-link/magic-link-view.module.css new file mode 100644 index 000000000..12494153b --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link/magic-link-view.module.css @@ -0,0 +1,22 @@ +.form { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--rs-space-5); +} + +.button { + width: 100%; +} + +.field { + width: 100%; + position: relative; + margin-bottom: var(--rs-space-5); +} + +.error { + position: absolute; + top: calc(100% + 4px); +} diff --git a/web/sdk/react/views-new/auth/magic-link/magic-link-view.tsx b/web/sdk/react/views-new/auth/magic-link/magic-link-view.tsx new file mode 100644 index 000000000..aca9f623c --- /dev/null +++ b/web/sdk/react/views-new/auth/magic-link/magic-link-view.tsx @@ -0,0 +1,148 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Button, + Text, + Separator, + Flex, + InputField +} from '@raystack/apsara-v1'; +import { ComponentPropsWithRef, ReactNode, useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import isEmail from 'validator/lib/isEmail'; +import { useMutation } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { HttpErrorResponse } from '~/react/utils'; +import { + AuthContainer, + type AuthContainerProps +} from '~/react/components/auth-container'; +import { AuthHeader } from '~/react/components/auth-header'; +import styles from './magic-link-view.module.css'; + +export type MagicLinkViewProps = ComponentPropsWithRef<'div'> & + AuthContainerProps & { + logo?: ReactNode; + title?: string; + open?: boolean; + inline?: boolean; + }; + +const emailSchema = yup.object({ + email: yup + .string() + .trim() + .required() + .test( + 'is-valid', + () => 'Please enter a valid email address.', + value => + value ? isEmail(value) : new yup.ValidationError('Invalid value') + ) +}); + +type FormData = yup.InferType; + +export const MagicLinkView = ({ + logo, + title = 'Login to Raystack', + open = false, + inline = false, + ...props +}: MagicLinkViewProps) => { + const { config } = useFrontier(); + const [visible, setVisible] = useState(open); + + const { mutateAsync: authenticate, isPending } = useMutation( + FrontierServiceQueries.authenticate + ); + + const { + watch, + handleSubmit, + setError, + register, + formState: { errors } + } = useForm({ + resolver: yupResolver(emailSchema) + }); + + const magicLinkHandler = useCallback( + async (data: FormData) => { + try { + const response = await authenticate({ + strategyName: 'mailotp', + email: data.email, + callbackUrl: config.callbackUrl + }); + + const searchParams = new URLSearchParams({ + state: response.state || '', + email: data.email + }); + + // @ts-ignore + window.location = `${ + config.redirectMagicLinkVerify + }?${searchParams.toString()}`; + } catch (err: unknown) { + if (err instanceof Response && err?.status === 400) { + const message = + (err as HttpErrorResponse)?.error?.message || 'Bad Request'; + setError('email', { message }); + } else { + setError('email', { message: 'An unexpected error occurred' }); + } + } + }, + [authenticate, config.callbackUrl, config.redirectMagicLinkVerify, setError] + ); + + const email = watch('email', ''); + + const formContent = !visible ? ( + + ) : ( +
+ {!open && } + + + + {errors.email && String(errors.email?.message)} + + + + + ); + + if (inline) return formContent; + + return ( + + + {formContent} + + ); +}; diff --git a/web/sdk/react/views-new/auth/sign-in/index.ts b/web/sdk/react/views-new/auth/sign-in/index.ts new file mode 100644 index 000000000..9f2179e10 --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-in/index.ts @@ -0,0 +1 @@ +export { SignInView } from './sign-in-view'; diff --git a/web/sdk/react/views-new/auth/sign-in/sign-in-view.module.css b/web/sdk/react/views-new/auth/sign-in/sign-in-view.module.css new file mode 100644 index 000000000..b950c75aa --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-in/sign-in-view.module.css @@ -0,0 +1,3 @@ +.redirectLink { + color: var(--rs-color-foreground-accent-primary); +} diff --git a/web/sdk/react/views-new/auth/sign-in/sign-in-view.tsx b/web/sdk/react/views-new/auth/sign-in/sign-in-view.tsx new file mode 100644 index 000000000..7d5e03a12 --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-in/sign-in-view.tsx @@ -0,0 +1,95 @@ +import { Link, Text, Flex } from '@raystack/apsara-v1'; +import { ComponentPropsWithRef, ReactNode, useCallback } from 'react'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { + AuthContainer, + type AuthContainerProps +} from '~/react/components/auth-container'; +import { AuthHeader } from '~/react/components/auth-header'; +import { AuthOIDCButton } from '~/react/components/auth-oidc-button'; +import { MagicLinkView } from '../magic-link/magic-link-view'; +import styles from './sign-in-view.module.css'; + +export type SignInViewProps = ComponentPropsWithRef<'div'> & + AuthContainerProps & { + logo?: ReactNode; + title?: string; + excludes?: string[]; + footer?: boolean; + }; + +export const SignInView = ({ + logo, + title = 'Login to Raystack', + excludes = [], + footer = true, + ...props +}: SignInViewProps) => { + const { config } = useFrontier(); + + const { data: strategiesData } = useQuery( + FrontierServiceQueries.listAuthStrategies + ); + const strategies = strategiesData?.strategies || []; + + const { mutateAsync: authenticate } = useMutation( + FrontierServiceQueries.authenticate + ); + + const clickHandler = useCallback( + async (name?: string) => { + if (!name) return; + try { + const response = await authenticate({ + strategyName: name, + callbackUrl: config.callbackUrl + }); + if (response.endpoint) { + window.location.href = response.endpoint; + } + } catch (error) { + console.error('Authentication failed:', error); + } + }, + [authenticate, config.callbackUrl] + ); + + const mailotp = strategies.find(s => s.name === 'mailotp'); + const filteredOIDC = strategies + .filter(s => s.name !== 'mailotp') + .filter(s => !excludes.includes(s.name ?? '')); + + return ( + + + + {filteredOIDC.map((s, index) => { + return ( + clickHandler(s.name)} + provider={s.name || ''} + data-test-id="frontier-sdk-oidc-btn" + /> + ); + })} + + {mailotp && } + + {footer && ( + + Don't have an account?{' '} + + Signup + + + )} + + ); +}; diff --git a/web/sdk/react/views-new/auth/sign-up/index.ts b/web/sdk/react/views-new/auth/sign-up/index.ts new file mode 100644 index 000000000..b57ffaaa0 --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-up/index.ts @@ -0,0 +1 @@ +export { SignUpView } from './sign-up-view'; diff --git a/web/sdk/react/views-new/auth/sign-up/sign-up-view.module.css b/web/sdk/react/views-new/auth/sign-up/sign-up-view.module.css new file mode 100644 index 000000000..b950c75aa --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-up/sign-up-view.module.css @@ -0,0 +1,3 @@ +.redirectLink { + color: var(--rs-color-foreground-accent-primary); +} diff --git a/web/sdk/react/views-new/auth/sign-up/sign-up-view.tsx b/web/sdk/react/views-new/auth/sign-up/sign-up-view.tsx new file mode 100644 index 000000000..e0e8a213d --- /dev/null +++ b/web/sdk/react/views-new/auth/sign-up/sign-up-view.tsx @@ -0,0 +1,91 @@ +import { Link, Text, Flex } from '@raystack/apsara-v1'; +import { ComponentPropsWithRef, ReactNode, useCallback } from 'react'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { + AuthContainer, + type AuthContainerProps +} from '~/react/components/auth-container'; +import { AuthHeader } from '~/react/components/auth-header'; +import { AuthOIDCButton } from '~/react/components/auth-oidc-button'; +import { MagicLinkView } from '../magic-link/magic-link-view'; +import styles from './sign-up-view.module.css'; + +export type SignUpViewProps = ComponentPropsWithRef<'div'> & + AuthContainerProps & { + logo?: ReactNode; + title?: string; + excludes?: string[]; + }; + +export const SignUpView = ({ + logo, + title = 'Create your account', + excludes = [], + ...props +}: SignUpViewProps) => { + const { config } = useFrontier(); + + const { data: strategiesData } = useQuery( + FrontierServiceQueries.listAuthStrategies + ); + const strategies = strategiesData?.strategies || []; + + const { mutateAsync: authenticate } = useMutation( + FrontierServiceQueries.authenticate + ); + + const clickHandler = useCallback( + async (name?: string) => { + if (!name) return; + try { + const response = await authenticate({ + strategyName: name, + callbackUrl: config.callbackUrl + }); + if (response.endpoint) { + window.location.href = response.endpoint; + } + } catch (error) { + console.error('Authentication failed:', error); + } + }, + [authenticate, config.callbackUrl] + ); + + const mailotp = strategies.find(s => s.name === 'mailotp'); + const filteredOIDC = strategies + .filter(s => s.name !== 'mailotp') + .filter(s => !excludes.includes(s.name ?? '')); + + return ( + + + + {filteredOIDC.map((s, index) => { + return ( + clickHandler(s.name)} + provider={s.name || ''} + data-test-id="frontier-sdk-signup-page-oidc-btn" + /> + ); + })} + + {mailotp && } + + + Already have an account?{' '} + + Login + + + + ); +}; diff --git a/web/sdk/react/views-new/auth/subscribe/index.ts b/web/sdk/react/views-new/auth/subscribe/index.ts new file mode 100644 index 000000000..a9e1a79ea --- /dev/null +++ b/web/sdk/react/views-new/auth/subscribe/index.ts @@ -0,0 +1 @@ +export { SubscribeView } from './subscribe-view'; diff --git a/web/sdk/react/views-new/auth/subscribe/subscribe-view.module.css b/web/sdk/react/views-new/auth/subscribe/subscribe-view.module.css new file mode 100644 index 000000000..7b1893598 --- /dev/null +++ b/web/sdk/react/views-new/auth/subscribe/subscribe-view.module.css @@ -0,0 +1,56 @@ +.button { + width: 100%; +} + +.subscribeContainer { + width: 360px; + padding: var(--rs-space-8); + box-sizing: border-box; + box-shadow: var(--rs-shadow-feather); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); +} + +.formField { + width: 100%; +} + +.subscribeContainer :global(input::placeholder) { + color: var(--rs-color-foreground-base-secondary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.subscribeContainer :global(input) { + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.subscribeTitle { + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-large); + font-style: normal; + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-large); + letter-spacing: var(--rs-letter-spacing-large); +} + +.subscribeDescription { + color: var(--rs-color-foreground-base-secondary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-regular); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); +} diff --git a/web/sdk/react/views-new/auth/subscribe/subscribe-view.tsx b/web/sdk/react/views-new/auth/subscribe/subscribe-view.tsx new file mode 100644 index 000000000..da8fd5a12 --- /dev/null +++ b/web/sdk/react/views-new/auth/subscribe/subscribe-view.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { ReactNode, useState } from 'react'; +import * as yup from 'yup'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Button, + Flex, + Text, + InputField, + Toast, + toastManager, + Image, + EmptyState +} from '@raystack/apsara-v1'; +import { useMutation } from '@connectrpc/connect-query'; +import { + CreateProspectPublicRequestSchema, + FrontierServiceQueries +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import checkCircle from '~/react/assets/check-circle.svg'; +import styles from './subscribe-view.module.css'; + +const schema = yup.object({ + name: yup.string().required('Name is required'), + email: yup.string().email('Invalid email').required('Email is required'), + contactNumber: yup + .string() + .transform(value => (value.trim() === '' ? null : value)) + .nullable() + .test( + 'digits-only', + 'Must contain only numbers with country code', + value => { + if (!value?.trim()) return true; + return /^[+\d\s\-()]+$/.test(value); + } + ) + .optional() +}); + +type FormData = yup.InferType; + +interface ExtendedFormData extends FormData { + activity: string; + source?: string; + metadata?: { + medium?: string; + }; +} + +export type SubscribeViewProps = { + title?: string; + desc?: string; + activity?: string; + medium?: string; + source?: string; + confirmSection?: ReactNode; + // eslint-disable-next-line no-unused-vars + onSubmit?: (data: FormData) => void; +}; + +const DEFAULT_TITLE = 'Updates, News & Events'; +const DEFAULT_DESCRIPTION = + 'Stay informed on new features, improvements, and key updates'; +const DEFAULT_SUCCESS_TITLE = 'Thank you for subscribing!'; +const DEFAULT_SUCCESS_DESCRIPTION = + 'You have successfully subscribed to our list. We will let you know about the updates.'; + +const ConfirmSection = () => { + return ( + + + } + heading={DEFAULT_SUCCESS_TITLE} + subHeading={DEFAULT_SUCCESS_DESCRIPTION} + /> + + + ); +}; + +export const SubscribeView = ({ + title = DEFAULT_TITLE, + desc = DEFAULT_DESCRIPTION, + activity = 'newsletter', + medium, + source, + confirmSection = , + onSubmit +}: SubscribeViewProps) => { + const [isSuccess, setIsSuccess] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const { mutateAsync: createProspect } = useMutation( + FrontierServiceQueries.createProspectPublic, + { + onError: (err: Error) => { + console.error('Frontier SDK: Error while submitting the form', err); + toastManager.add({ + title: 'Something went wrong. Please try again.', + description: err?.message, + type: 'error' + }); + } + } + ); + + async function onFormSubmit(data: FormData) { + const formData: ExtendedFormData = { ...data, activity }; + if (medium) { + formData.metadata = { ...formData.metadata, medium }; + } + if (source) { + formData.source = source; + } + + await createProspect( + create(CreateProspectPublicRequestSchema, { + name: formData.name, + email: formData.email, + phone: formData?.contactNumber || undefined, + activity: formData.activity, + source: formData.source, + metadata: formData.metadata + }) + ); + setIsSuccess(true); + onSubmit?.(data); + } + + if (isSuccess) { + return <>{confirmSection}; + } + + return ( + +
+ + + + {title} + + + {desc} + + + + + + + + + +
+ ); +}; diff --git a/web/sdk/react/views-new/auth/updates/index.ts b/web/sdk/react/views-new/auth/updates/index.ts new file mode 100644 index 000000000..5ca8b6e39 --- /dev/null +++ b/web/sdk/react/views-new/auth/updates/index.ts @@ -0,0 +1 @@ +export { UpdatesView } from './updates-view'; diff --git a/web/sdk/react/views-new/auth/updates/updates-view.module.css b/web/sdk/react/views-new/auth/updates/updates-view.module.css new file mode 100644 index 000000000..88f08339b --- /dev/null +++ b/web/sdk/react/views-new/auth/updates/updates-view.module.css @@ -0,0 +1,12 @@ +.button { + width: 100%; +} + +.updatesContainer { + width: 100%; + max-width: 496px; + padding: var(--rs-space-9); + box-sizing: border-box; + box-shadow: var(--rs-shadow-soft); + border-radius: var(--rs-radius-2); +} diff --git a/web/sdk/react/views-new/auth/updates/updates-view.tsx b/web/sdk/react/views-new/auth/updates/updates-view.tsx new file mode 100644 index 000000000..0479b6275 --- /dev/null +++ b/web/sdk/react/views-new/auth/updates/updates-view.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { type ReactNode } from 'react'; +import { Button, Flex, Text, Switch, Skeleton } from '@raystack/apsara-v1'; +import { Controller, useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { PREFERENCE_OPTIONS } from '~/react/utils/constants'; +import { usePreferences } from '~/react/hooks/usePreferences'; +import { AuthContainer } from '~/react/components/auth-container'; +import { AuthHeader } from '~/react/components/auth-header'; +import styles from './updates-view.module.css'; + +const schema = yup.object({ + [PREFERENCE_OPTIONS.NEWSLETTER]: yup.boolean().optional() +}); + +type FormData = yup.InferType; + +export type UpdatesViewProps = { + logo?: ReactNode; + title?: string; + preferenceTitle?: string; + preferenceDescription?: string; + // eslint-disable-next-line no-unused-vars + onSubmit?: (data: FormData) => void; +}; + +export const UpdatesView = ({ + logo, + title = 'Subscribe for updates', + preferenceTitle = 'Updates, News & Events', + preferenceDescription = 'Stay informed on new features, improvements, and key updates', + onSubmit +}: UpdatesViewProps) => { + const { preferences, isFetching, updatePreferences } = usePreferences(); + + const newsletterValue = + preferences?.[PREFERENCE_OPTIONS.NEWSLETTER]?.value === 'true'; + + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + values: { + [PREFERENCE_OPTIONS.NEWSLETTER]: newsletterValue + }, + resolver: yupResolver(schema) + }); + + async function onFormSubmit(data: FormData) { + return updatePreferences([ + { + name: PREFERENCE_OPTIONS.NEWSLETTER, + value: String(data[PREFERENCE_OPTIONS.NEWSLETTER]) + } + ]) + .then(() => onSubmit?.(data)) + .catch(err => { + console.error('frontier:sdk:: error during submit', err); + }); + } + return ( + + +
+ + + + + {preferenceTitle} + + {isFetching ? ( + + ) : ( + ( + + )} + control={control} + name={PREFERENCE_OPTIONS.NEWSLETTER} + /> + )} + + + {preferenceDescription} + + + + +
+
+ ); +}; diff --git a/web/sdk/react/views-new/billing/components/confirm-cycle-switch-dialog.tsx b/web/sdk/react/views-new/billing/components/confirm-cycle-switch-dialog.tsx index a50904dac..bb9d39d6d 100644 --- a/web/sdk/react/views-new/billing/components/confirm-cycle-switch-dialog.tsx +++ b/web/sdk/react/views-new/billing/components/confirm-cycle-switch-dialog.tsx @@ -14,7 +14,7 @@ import { useFrontier } from '../../../contexts/FrontierContext'; import { getPlanIntervalName, getPlanPrice } from '../../../utils'; import { DEFAULT_DATE_FORMAT } from '../../../utils/constants'; import { timestampToDayjs } from '../../../../utils/timestamp'; -import { usePlans } from '../../../views/plans/hooks/usePlans'; +import { usePlans } from '../../plans/hooks/use-plans'; import { isEmpty } from 'lodash'; export interface ConfirmCycleSwitchPayload { diff --git a/web/sdk/react/views-new/create-organization/create-organization-view.module.css b/web/sdk/react/views-new/create-organization/create-organization-view.module.css new file mode 100644 index 000000000..82fc5ccb0 --- /dev/null +++ b/web/sdk/react/views-new/create-organization/create-organization-view.module.css @@ -0,0 +1,27 @@ +.container { + min-width: 220px; + max-width: 480px; + width: 100%; + color: var(--rs-color-foreground-base-primary); +} + +.description { + text-align: center; +} + +.form { + width: 100%; +} + +.card { + margin: auto; + width: 100%; + max-width: 384px; + padding: var(--rs-space-9); + border-radius: var(--rs-radius-4); + box-shadow: var(--rs-shadow-soft); +} + +.submit { + width: 100%; +} diff --git a/web/sdk/react/views-new/create-organization/create-organization-view.tsx b/web/sdk/react/views-new/create-organization/create-organization-view.tsx new file mode 100644 index 000000000..58bc3d683 --- /dev/null +++ b/web/sdk/react/views-new/create-organization/create-organization-view.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Button, + Text, + Headline, + Flex, + InputField, + toastManager +} from '@raystack/apsara-v1'; +import { ComponentPropsWithRef } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateOrganizationRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useTerminology } from '~/react/hooks/useTerminology'; +import styles from './create-organization-view.module.css'; + +export type CreateOrganizationViewProps = ComponentPropsWithRef<'div'> & { + title?: string; + description?: string; +}; + +const schema = yup + .object({ + title: yup.string().required(), + name: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +export const CreateOrganizationView = ({ + title = 'Create a new organization', + description = 'Organizations are shared environments where team can work on assets, connections and data operations.', + ...props +}: CreateOrganizationViewProps) => { + const t = useTerminology(); + const { + handleSubmit, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(schema) + }); + + const { mutateAsync: createOrganization } = useMutation( + FrontierServiceQueries.createOrganization, + { + onError: (err: Error) => { + toastManager.add({ + title: 'Failed to create organization', + description: err?.message, + type: 'error' + }); + } + } + ); + + async function onSubmit(data: FormData) { + const response = await createOrganization( + create(CreateOrganizationRequestSchema, { + body: { + title: data.title, + name: data.name + } + }) + ); + const organization = response.organization; + if (organization?.name) { + // @ts-ignore + window.location = `${window.location.origin}/${organization.name}`; + } + } + + return ( + + + {title} + + {description} + + +
+ + + + + +
+
+ ); +}; diff --git a/web/sdk/react/views-new/create-organization/index.ts b/web/sdk/react/views-new/create-organization/index.ts new file mode 100644 index 000000000..ed9b3185a --- /dev/null +++ b/web/sdk/react/views-new/create-organization/index.ts @@ -0,0 +1,2 @@ +export { CreateOrganizationView } from './create-organization-view'; +export type { CreateOrganizationViewProps } from './create-organization-view'; diff --git a/web/sdk/react/views-new/preferences/preferences-view.tsx b/web/sdk/react/views-new/preferences/preferences-view.tsx index 002d1de40..b6169b183 100644 --- a/web/sdk/react/views-new/preferences/preferences-view.tsx +++ b/web/sdk/react/views-new/preferences/preferences-view.tsx @@ -6,7 +6,7 @@ import { ViewHeader } from '~/react/components/view-header'; import { usePreferences } from '~/react/hooks/usePreferences'; import { PREFERENCE_OPTIONS } from '~/react/utils/constants'; import { PreferenceRow } from './components/preference-row'; -import { useTheme } from '@raystack/apsara'; +import { useTheme } from '@raystack/apsara-v1'; import styles from './preferences-view.module.css'; import { ReactNode } from 'react'; diff --git a/web/sdk/react/views-new/sessions/components/revoke-session-dialog.tsx b/web/sdk/react/views-new/sessions/components/revoke-session-dialog.tsx index ad7e29944..600eafd5d 100644 --- a/web/sdk/react/views-new/sessions/components/revoke-session-dialog.tsx +++ b/web/sdk/react/views-new/sessions/components/revoke-session-dialog.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'; import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { Button, Dialog, Flex, Skeleton, Text } from '@raystack/apsara-v1'; -import { useSessions } from '~/react/hooks/useSessions'; +import { useSessions } from '~/react/hooks/useSessionsV1'; import { RevokeSessionConfirmDialog } from './revoke-session-confirm-dialog'; import styles from './revoke-session-dialog.module.css'; diff --git a/web/sdk/react/views-new/sessions/sessions-view.tsx b/web/sdk/react/views-new/sessions/sessions-view.tsx index 94ae30ce7..068dfcd84 100644 --- a/web/sdk/react/views-new/sessions/sessions-view.tsx +++ b/web/sdk/react/views-new/sessions/sessions-view.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { DotFilledIcon } from '@radix-ui/react-icons'; import { Button, Flex, Skeleton, Text } from '@raystack/apsara-v1'; -import { useSessions } from '~/react/hooks/useSessions'; +import { useSessions } from '~/react/hooks/useSessionsV1'; import { ViewContainer } from '~/react/components/view-container'; import { ViewHeader } from '~/react/components/view-header'; import { RevokeSessionDialog } from './components/revoke-session-dialog'; diff --git a/web/sdk/react/views-new/tokens/tokens-view.tsx b/web/sdk/react/views-new/tokens/tokens-view.tsx index cec4e9295..c37d0d64c 100644 --- a/web/sdk/react/views-new/tokens/tokens-view.tsx +++ b/web/sdk/react/views-new/tokens/tokens-view.tsx @@ -26,7 +26,7 @@ import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { useDebounceValue } from 'usehooks-ts'; import { useFrontier } from '~/react/contexts/FrontierContext'; import { useBillingPermission } from '~/react/hooks/useBillingPermission'; -import { useTokens } from '~/react/hooks/useTokens'; +import { useTokens } from '~/react/hooks/useTokensV1'; import { AuthTooltipMessage, getFormattedNumberString } from '~/react/utils'; import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import { diff --git a/web/sdk/tsup.config.ts b/web/sdk/tsup.config.ts index 83a3bb202..16c556e26 100644 --- a/web/sdk/tsup.config.ts +++ b/web/sdk/tsup.config.ts @@ -14,7 +14,7 @@ export default defineConfig(() => [ format: ['cjs', 'esm'], dts: true }, - // React APIs + // React APIs (legacy) { entry: ['react/index.ts'], outDir: 'react/dist', @@ -22,7 +22,24 @@ export default defineConfig(() => [ js: "'use client'" }, format: ['cjs', 'esm'], - external: ['react', 'react-dom', 'svelte', 'vue', 'solid-js'], + external: ['react', 'react-dom'], + dts: true, + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + '.jpg': 'dataurl' + }, + esbuildPlugins: [cssModulesPlugin({ localsConvention: 'camelCase' })] + }, + // React APIs (client - new views, apsara v1) + { + entry: ['react/client.ts'], + outDir: 'react/dist', + banner: { + js: "'use client'" + }, + format: ['cjs', 'esm'], + external: ['react', 'react-dom'], dts: true, loader: { '.svg': 'dataurl',