@polyphron/viper-react 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,17 @@ function SignIn() {
71
71
 
72
72
  ## The hooks
73
73
 
74
+ With personal tokens or device sign-in enabled, `useAuth().auth` exposes
75
+ `createToken`, `tokens`, `revokeToken`, `startDeviceSignIn`, `authorizeDevice`
76
+ and `pollDeviceSignIn`. Polling an approved device saves its normal session,
77
+ so `useUser()` updates. Only display the user code and verification URI;
78
+ keep the device code private and honor each polling result's interval.
79
+
80
+ With email change confirmation enabled, `useAuth()` exposes
81
+ `auth.requestEmailChange(newEmail)` and `auth.confirmEmailChange(token)`.
82
+ Both need the current user's session. After confirming, call `auth.me()`
83
+ to refresh the email shown by `useUser()`.
84
+
74
85
  | Hook | What it gives you |
75
86
  | --- | --- |
76
87
  | `useViper()` | The `Viper` client — writes go through it: `viper.collection("posts").create(…)` |
@@ -78,6 +89,13 @@ function SignIn() {
78
89
  | `useRecord(collection, id, options)` | One record, same shape |
79
90
  | `useAuth(collection)` | `{ user, auth }` for an auth collection |
80
91
  | `useSession()` / `useUser()` | The session / the logged-in user, re-rendering on change |
92
+ | `useSessionOf(client, read)` | The same for any client with `onSessionChange` (the admin client, say); `read` returns what to show, like `() => admin.user` |
93
+ | `useAuthMethods({ collection?, enabled? })` | How to sign in here (methods, sign-up, sub-app branding), or `null` while it loads |
94
+ | `useSessions()` | The user's active sessions: `{ sessions, error, loading, refresh, revoke, logoutEverywhere }` |
95
+ | `useInvites()` | Invites the user sent: `{ invites, error, loading, refresh, invite, cancel }` |
96
+ | `useTenants(name)` | The user's memberships of tenant collection `name`; never the last user's list |
97
+ | `useRun(name, started, { every? })` | Follows a background workflow's run until it ends: `{ data, error, pending }` |
98
+ | `useAppIdentity()` / `useAppManagement()` | The user's app role and ceilings; opt-in role and member edits. Both give `{ identity, error, loading, refresh }`, management adds `roles`, `members` and the writes. A 403 on the management calls keeps `identity` |
81
99
  | `useRealtime(topics, onEvent)` | Watch changes yourself; returns the connection status |
82
100
  | `useRealtimeStatus()` | `"closed"`, `"connecting"` or `"live"` |
83
101
 
@@ -95,6 +113,14 @@ flight, so lists don't flash.
95
113
  - With `live: true`, the list is also re-fetched once after the socket comes
96
114
  back from a drop, since events may have been missed while it was down.
97
115
 
116
+ `useSessions`, `useInvites` and `useAppIdentity` all give `loading` (true
117
+ until the first answer for this user), `error` (an `Error` or `null`) and
118
+ `refresh()`, which resolves once the new answer is in. They key their state on
119
+ the signed-in user, so after a user switch you never see the last user's data.
120
+ `useLoaded(subject, load)` is the helper they and `useRecords` are built on;
121
+ use it to write your own fetching hook. It returns the same `Query` shape as
122
+ `useRecords`.
123
+
98
124
  `@polyphron/viper-client` is a peer dependency: install it next to this
99
125
  package, so the app and the hooks share one client.
100
126
 
@@ -200,6 +226,50 @@ collection.utils.refetch() // ask for the page again
200
226
  - `collection.utils.apply(event)` shows a change you made yourself (a save)
201
227
  without waiting for its realtime event.
202
228
 
229
+ ### Any endpoint as a collection
230
+
231
+ For data that isn't records (settings, keys, logs, anything an endpoint hands
232
+ back whole), `fetched(owner, options)` makes a TanStack DB collection from a
233
+ fetch, and `useFetched(collection)` reads it as
234
+ `{ data, error, isPending, isFetching, updatedAt, refetch }`.
235
+
236
+ ```tsx
237
+ import { fetched, refetchFetched, useFetched } from "@polyphron/viper-react/db"
238
+
239
+ const settings = (p: string) =>
240
+ fetched<Settings>(client, {
241
+ key: ["projects", p, "settings"],
242
+ fetch: (signal) => api.settings(p, { signal }),
243
+ })
244
+
245
+ const { data } = useFetched(settings(projectId))
246
+
247
+ await save(input)
248
+ await refetchFetched(client, ["projects", projectId]) // every key under it
249
+ ```
250
+
251
+ - The same `owner` and `key` always give the same collection, so every
252
+ reader shares one load. A list (pass `getKey`) keeps the server's order; a
253
+ single object is one row.
254
+ - It loads again when told to (`refetchFetched` by key prefix, or
255
+ `collection.utils.refetch()`), on `refetchInterval` while read, and when
256
+ the window comes back after `staleTime` (30 seconds by default). Only
257
+ changed rows are rewritten, so the rest keep their identity.
258
+ - `collection.utils.set(data)` puts in a save's answer with no request.
259
+ `await collection.utils.ready()` waits for the first load, from a route
260
+ loader say. `forgetFetched(owner, prefix)` drops collections (after a
261
+ delete).
262
+ - Network errors retry twice; a "no" or "not found" doesn't. The error is
263
+ in `error`, and the last good data stays.
264
+ - `useFetched(collection, { keepPrevious: true })` keeps the last data on
265
+ screen while a new collection (the next page, a new filter) loads.
266
+ `useFetchedAll(collections)` reads several, one per project say.
267
+
268
+ The Viper dashboard reads the whole admin API this way, through
269
+ `@polyphron/viper-react/db/admin` (`adminCollections(admin)`). That entry
270
+ stays in the workspace, like the admin client it wraps, and isn't
271
+ published.
272
+
203
273
  ### Recipe: typed collections, a join and an optimistic insert
204
274
 
205
275
  1. Write the types once with `viper typegen --out src/viper-types.ts` (see
package/dist/auth.d.ts CHANGED
@@ -1,17 +1,79 @@
1
- import type { AuthService, RecordModel, Session } from "@polyphron/viper-client";
1
+ import type { AuthMethods, AuthService, Invite, InviteInput, RecordModel, Session, UserSession } from "@polyphron/viper-client";
2
+ /**
3
+ * The session of any client that announces changes (`Viper`, `ViperAdmin`),
4
+ * re-rendering when it changes. `read` returns what to show from the client,
5
+ * like `() => admin.user`. It must be stable between changes.
6
+ */
7
+ export declare function useSessionOf<S>(client: {
8
+ onSessionChange(listener: () => void): () => void;
9
+ }, read: () => S | null): S | null;
2
10
  /** The current session, re-rendering on login, logout and refresh. */
3
11
  export declare function useSession(): Session | null;
12
+ /** The signed-in user's active sessions, with revoke and log-out actions. */
13
+ export declare function useSessions(): {
14
+ sessions: UserSession[] | null;
15
+ error: Error | null;
16
+ loading: boolean;
17
+ refresh: () => Promise<void>;
18
+ revoke: (sessionId: string) => Promise<void>;
19
+ logoutEverywhere: () => Promise<void>;
20
+ };
21
+ /** The invites the signed-in user sent, with send and take-back actions. */
22
+ export declare function useInvites(): {
23
+ invites: Invite[] | null;
24
+ error: Error | null;
25
+ loading: boolean;
26
+ refresh: () => Promise<void>;
27
+ invite: (input: InviteInput) => Promise<Invite>;
28
+ cancel: (id: string) => Promise<void>;
29
+ };
4
30
  /** The logged-in user, or null when signed out. */
5
31
  export declare function useUser<T extends RecordModel = RecordModel>(): T | null;
6
32
  /**
7
- * The logins of one auth collection, plus the user they belong to.
33
+ * The project's sign-in, plus the user it belongs to. Without a
34
+ * collection it is the users table's (`viper.auth`); an old project with
35
+ * several auth collections names one.
8
36
  *
9
37
  * ```tsx
10
- * const { user, auth } = useAuth("users")
38
+ * const { user, auth } = useAuth()
11
39
  * await auth.loginWithPassword(email, password)
40
+ * await auth.requestEmailChange(newEmail)
41
+ * await auth.confirmEmailChange(token)
42
+ * await auth.me() // refresh the user after confirmation
43
+ * const proof = await auth.beginOAuth2("google", location.href)
44
+ * // Keep proof.codeVerifier across navigation to proof.url.
45
+ * const links = await auth.oauth2Links()
12
46
  * ```
13
47
  */
14
- export declare function useAuth<T extends RecordModel = RecordModel>(collection: string): {
48
+ export declare function useAuth<T extends RecordModel = RecordModel>(collection?: string): {
15
49
  user: T | null;
16
50
  auth: AuthService<T>;
17
51
  };
52
+ /**
53
+ * How to sign in here: the ways on offer, sign-up, and the sub-app's name
54
+ * and branding when the client has one (`new Viper({ app })`). Null while
55
+ * it loads, and when the load fails: the sign-in calls report their own
56
+ * errors. Name an auth `collection` when the project has several, and pass
57
+ * `enabled: false` to hold the request back. For a sign-in page.
58
+ *
59
+ * ```tsx
60
+ * const methods = useAuthMethods()
61
+ * return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
62
+ * ```
63
+ */
64
+ export declare function useAuthMethods(options?: {
65
+ collection?: string;
66
+ enabled?: boolean;
67
+ }): AuthMethods | null;
68
+ /**
69
+ * The signed-in user's memberships of tenant collection `name` (like
70
+ * "orgs"), each with its role and the tenant under `expand.tenant`. Empty
71
+ * while signed out, null while it loads (and when the load fails). It
72
+ * loads again when the user changes, and never shows the last user's list.
73
+ *
74
+ * ```tsx
75
+ * const orgs = useTenants("orgs")
76
+ * return orgs?.map((m) => <li key={m.id}>{m.expand?.tenant?.name}</li>)
77
+ * ```
78
+ */
79
+ export declare function useTenants(name: string): RecordModel[] | null;
package/dist/auth.js CHANGED
@@ -1,24 +1,121 @@
1
- import { useSyncExternalStore } from "react";
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { asError, useLoaded } from "./loaded.js";
2
3
  import { useViper } from "./provider.js";
4
+ /**
5
+ * The session of any client that announces changes (`Viper`, `ViperAdmin`),
6
+ * re-rendering when it changes. `read` returns what to show from the client,
7
+ * like `() => admin.user`. It must be stable between changes.
8
+ */
9
+ export function useSessionOf(client, read) {
10
+ return useSyncExternalStore((listener) => client.onSessionChange(listener), read, () => null);
11
+ }
3
12
  /** The current session, re-rendering on login, logout and refresh. */
4
13
  export function useSession() {
5
14
  const viper = useViper();
6
- return useSyncExternalStore((listener) => viper.onSessionChange(listener), () => viper.session, () => null);
15
+ return useSessionOf(viper, () => viper.session);
16
+ }
17
+ /** The signed-in user's active sessions, with revoke and log-out actions. */
18
+ export function useSessions() {
19
+ const viper = useViper();
20
+ const userId = useUser()?.id ?? null;
21
+ const { data, error, loading, reload } = useLoaded(userId, useCallback((signal) => userId ? viper.auth.sessions({ signal }) : Promise.resolve(null), [viper, userId]));
22
+ return {
23
+ sessions: data,
24
+ error: error ? asError(error) : null,
25
+ loading,
26
+ refresh: reload,
27
+ revoke: async (sessionId) => {
28
+ await viper.auth.revokeSession(sessionId);
29
+ await reload();
30
+ },
31
+ logoutEverywhere: () => viper.auth.logoutEverywhere(),
32
+ };
33
+ }
34
+ /** The invites the signed-in user sent, with send and take-back actions. */
35
+ export function useInvites() {
36
+ const viper = useViper();
37
+ const userId = useUser()?.id ?? null;
38
+ const { data, error, loading, reload } = useLoaded(userId, useCallback((signal) => userId ? viper.auth.invites({ signal }) : Promise.resolve(null), [viper, userId]));
39
+ return {
40
+ invites: data,
41
+ error: error ? asError(error) : null,
42
+ loading,
43
+ refresh: reload,
44
+ invite: async (input) => {
45
+ const made = await viper.auth.invite(input);
46
+ await reload();
47
+ return made;
48
+ },
49
+ cancel: async (id) => {
50
+ await viper.auth.cancelInvite(id);
51
+ await reload();
52
+ },
53
+ };
7
54
  }
8
55
  /** The logged-in user, or null when signed out. */
9
56
  export function useUser() {
10
57
  return useSession()?.record ?? null;
11
58
  }
12
59
  /**
13
- * The logins of one auth collection, plus the user they belong to.
60
+ * The project's sign-in, plus the user it belongs to. Without a
61
+ * collection it is the users table's (`viper.auth`); an old project with
62
+ * several auth collections names one.
14
63
  *
15
64
  * ```tsx
16
- * const { user, auth } = useAuth("users")
65
+ * const { user, auth } = useAuth()
17
66
  * await auth.loginWithPassword(email, password)
67
+ * await auth.requestEmailChange(newEmail)
68
+ * await auth.confirmEmailChange(token)
69
+ * await auth.me() // refresh the user after confirmation
70
+ * const proof = await auth.beginOAuth2("google", location.href)
71
+ * // Keep proof.codeVerifier across navigation to proof.url.
72
+ * const links = await auth.oauth2Links()
18
73
  * ```
19
74
  */
20
75
  export function useAuth(collection) {
21
76
  const viper = useViper();
22
77
  const user = useUser();
23
- return { user, auth: viper.collection(collection).auth };
78
+ const auth = collection
79
+ ? viper.collection(collection).auth
80
+ : viper.auth;
81
+ return { user, auth };
82
+ }
83
+ /**
84
+ * How to sign in here: the ways on offer, sign-up, and the sub-app's name
85
+ * and branding when the client has one (`new Viper({ app })`). Null while
86
+ * it loads, and when the load fails: the sign-in calls report their own
87
+ * errors. Name an auth `collection` when the project has several, and pass
88
+ * `enabled: false` to hold the request back. For a sign-in page.
89
+ *
90
+ * ```tsx
91
+ * const methods = useAuthMethods()
92
+ * return <h1>{methods?.app?.branding.name ?? methods?.app?.name}</h1>
93
+ * ```
94
+ */
95
+ export function useAuthMethods(options = {}) {
96
+ const viper = useViper();
97
+ const { collection, enabled = true } = options;
98
+ const { data } = useLoaded(`${collection ?? ""}/${enabled}`, useCallback((signal) => enabled
99
+ ? (collection
100
+ ? viper.collection(collection).auth
101
+ : viper.auth).methods({ signal })
102
+ : Promise.resolve(null), [viper, collection, enabled]));
103
+ return data;
104
+ }
105
+ /**
106
+ * The signed-in user's memberships of tenant collection `name` (like
107
+ * "orgs"), each with its role and the tenant under `expand.tenant`. Empty
108
+ * while signed out, null while it loads (and when the load fails). It
109
+ * loads again when the user changes, and never shows the last user's list.
110
+ *
111
+ * ```tsx
112
+ * const orgs = useTenants("orgs")
113
+ * return orgs?.map((m) => <li key={m.id}>{m.expand?.tenant?.name}</li>)
114
+ * ```
115
+ */
116
+ export function useTenants(name) {
117
+ const viper = useViper();
118
+ const userId = useUser()?.id ?? "";
119
+ const { data } = useLoaded(`${name}/${userId}`, useCallback((signal) => viper.tenants(name, { signal }), [viper, name]));
120
+ return data;
24
121
  }
@@ -0,0 +1,121 @@
1
+ import type { Collection, CollectionConfig } from "@tanstack/db";
2
+ type Key = string | number;
3
+ /** Names what a fetch is, like `["projects", id, "keys"]`. */
4
+ export type FetchKey = readonly unknown[];
5
+ /** Where a fetch stands. */
6
+ export type FetchState = {
7
+ /** The row keys, in the server's order. */
8
+ order: Key[];
9
+ /** The last load's error, or null when it worked. */
10
+ error: unknown;
11
+ /** When the last good load finished (ms), or 0 before one. */
12
+ loadedAt: number;
13
+ /** True while a load is under way. */
14
+ fetching: boolean;
15
+ };
16
+ /** What a fetched collection adds under `collection.utils`. */
17
+ export type FetchUtils<D> = {
18
+ /** Where the fetch stands now. */
19
+ state: () => FetchState;
20
+ /** Calls `listener` whenever `state()` changes. Returns the unsubscribe. */
21
+ subscribe: (listener: () => void) => () => void;
22
+ /** Asks the server again. */
23
+ refetch: () => Promise<void>;
24
+ /** Resolves once a load has worked, or rejects if the first one failed. */
25
+ ready: () => Promise<void>;
26
+ /**
27
+ * Puts `data` in as if the server had sent it, with no request: the
28
+ * answer of a save, say.
29
+ */
30
+ set: (data: D) => void;
31
+ /** Counts a reader, so the timer and focus checks run. Returns the undo. */
32
+ watch: () => () => void;
33
+ /**
34
+ * Marks it stale: it loads again now if something reads it, else when
35
+ * something next does.
36
+ */
37
+ stale: () => Promise<void>;
38
+ };
39
+ /** A fetched collection: one list (or one object) from an endpoint. */
40
+ export type Fetched<D> = Collection<Row<D>, Key, FetchUtils<D>> & {
41
+ /** "list" for an array, "one" for a single object held as one row. */
42
+ kind: "list" | "one";
43
+ key: FetchKey;
44
+ };
45
+ /** The row type of a fetch: an item of a list, or the one object. */
46
+ export type Row<D> = D extends readonly (infer R extends object)[] ? R : D extends object ? D : never;
47
+ /** How to fetch, and how fresh to keep it. */
48
+ export type FetchOptions<D> = {
49
+ key: FetchKey;
50
+ fetch: (signal: AbortSignal) => Promise<D>;
51
+ /** Load again this often while read (ms), or from the data. */
52
+ refetchInterval?: number | false | ((data: D) => number | false);
53
+ /** How long a load counts as fresh (ms). Default 30 seconds. */
54
+ staleTime?: number;
55
+ /** Load a stale one again when the window comes back. Default true. */
56
+ refetchOnFocus?: boolean;
57
+ };
58
+ /**
59
+ * Options for `createCollection()`: what `fetch` gives, as rows. A list is
60
+ * keyed by `getKey`; a single object (leave `getKey` out) is one row.
61
+ */
62
+ export declare function fetchCollectionOptions<D>(options: FetchOptions<D> & {
63
+ getKey?: (row: Row<D>) => Key;
64
+ }): CollectionConfig<Row<D>, Key, never, FetchUtils<D>> & {
65
+ utils: FetchUtils<D>;
66
+ };
67
+ /**
68
+ * The fetched collection for `options.key` under `owner` (a client, say),
69
+ * made once and shared. For a list pass `getKey`; leave it out for one object.
70
+ */
71
+ export declare function fetched<D>(owner: object, options: FetchOptions<D> & {
72
+ getKey?: (row: Row<D>) => Key;
73
+ }): Fetched<D>;
74
+ /**
75
+ * Loads again every fetched collection under `owner` whose key starts with
76
+ * `prefix`: the ones on screen now, the rest when next read. Call it after a
77
+ * change, the way you'd invalidate a query.
78
+ */
79
+ export declare function refetchFetched(owner: object, prefix: FetchKey): Promise<void>;
80
+ /**
81
+ * Drops every fetched collection under `owner` whose key starts with
82
+ * `prefix`, so nothing shows its data again (after a project is deleted).
83
+ */
84
+ export declare function forgetFetched(owner: object, prefix: FetchKey): void;
85
+ /** What `useFetched` gives back. */
86
+ export type FetchedResult<D> = {
87
+ /** The list (or the one object), or undefined until it loads. */
88
+ data: D | undefined;
89
+ /** The last load's error, or null. */
90
+ error: Error | null;
91
+ isError: boolean;
92
+ /** True until the first load works or fails. */
93
+ isPending: boolean;
94
+ isSuccess: boolean;
95
+ /** True while any load is under way, the first or a later one. */
96
+ isFetching: boolean;
97
+ /** When the data last loaded (ms), or 0 before it has. */
98
+ updatedAt: number;
99
+ refetch: () => Promise<void>;
100
+ };
101
+ /**
102
+ * A fetched collection read as a list (or its one object), in the server's
103
+ * order. Pass null to read nothing yet. With `keepPrevious`, the last data
104
+ * stays on screen while a new collection (the next page, a new filter) loads.
105
+ *
106
+ * ```tsx
107
+ * const keys = useFetched(fetched(client, { key: ["keys"], fetch, getKey }))
108
+ * ```
109
+ */
110
+ export declare function useFetched<D>(collection: Fetched<D> | null | undefined, options?: {
111
+ keepPrevious?: boolean;
112
+ }): FetchedResult<D>;
113
+ /**
114
+ * Several fetched collections at once (one per project, say), in the same
115
+ * order: `{ data, error }` each, with `data` undefined until it loads.
116
+ */
117
+ export declare function useFetchedAll<D>(collections: readonly Fetched<D>[]): {
118
+ data: D | undefined;
119
+ error: Error | null;
120
+ }[];
121
+ export {};