@flow-industries/id 0.24.5 → 0.26.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.
@@ -1,4 +1,3 @@
1
- import { resolveIdHost } from "../id-host";
2
1
  import { getFlow, requireFlow } from "./create-flow";
3
2
  const POSITION_STYLE = {
4
3
  "top-right": { top: "0", right: "0" },
@@ -6,194 +5,50 @@ const POSITION_STYLE = {
6
5
  "bottom-right": { bottom: "0", right: "0" },
7
6
  "bottom-left": { bottom: "0", left: "0" },
8
7
  };
9
- /** Mount an account button that signs guests in or opens full-account settings. */
8
+ /** Mount the shared profile menu. Import @flow-industries/id/profile.css in non-Tailwind apps. */
10
9
  export function createProfileButton(options) {
11
10
  if (!("document" in globalThis))
12
11
  return { setTheme() { }, destroy() { } };
13
12
  const flow = options.flow ?? getFlow() ?? requireFlow();
14
- const host = resolveIdHost(options.host ?? flow.host);
15
- const createdContainer = options.container
16
- ? null
17
- : document.createElement("div");
18
- const container = options.container ?? createdContainer;
19
- if (!container)
20
- return { setTheme() { }, destroy() { } };
21
- if (createdContainer) {
22
- Object.assign(createdContainer.style, {
13
+ const container = options.container ?? document.createElement("div");
14
+ if (!options.container) {
15
+ Object.assign(container.style, {
23
16
  position: "fixed",
24
17
  zIndex: "2147483000",
25
18
  padding: options.padding ?? "1.5rem",
26
19
  ...POSITION_STYLE[options.position ?? "top-right"],
27
20
  });
28
- document.body.appendChild(createdContainer);
29
- }
30
- const button = document.createElement("button");
31
- button.type = "button";
32
- button.dataset.flowProfile = "";
33
- Object.assign(button.style, {
34
- display: "flex",
35
- boxSizing: "border-box",
36
- height: "var(--flow-profile-height, 2.25rem)",
37
- flexShrink: "0",
38
- alignItems: "center",
39
- gap: "8px",
40
- border: "0",
41
- borderRadius: "0",
42
- padding: "0.25rem 0.75rem",
43
- background: "transparent",
44
- color: "CanvasText",
45
- font: "inherit",
46
- fontSize: "0.875rem",
47
- lineHeight: "1.25rem",
48
- cursor: "pointer",
49
- pointerEvents: "auto",
50
- maxWidth: "240px",
51
- overflow: "hidden",
52
- textOverflow: "ellipsis",
53
- whiteSpace: "nowrap",
54
- });
55
- const notice = document.createElement("span");
56
- notice.setAttribute("role", "status");
57
- Object.assign(notice.style, {
58
- display: "block",
59
- font: "12px sans-serif",
60
- maxWidth: "240px",
61
- });
62
- const avatar = document.createElement("span");
63
- Object.assign(avatar.style, {
64
- display: "flex",
65
- alignItems: "center",
66
- justifyContent: "center",
67
- width: "calc(var(--flow-profile-height, 2.25rem) - 0.5rem)",
68
- height: "calc(var(--flow-profile-height, 2.25rem) - 0.5rem)",
69
- borderRadius: "var(--squircle-radius, 35%)",
70
- // biome-ignore lint/plugin: CSSStyleDeclaration requires the browser's property name.
71
- cornerShape: "superellipse(1.6)",
72
- overflow: "hidden",
73
- background: "color-mix(in srgb, currentColor 10%, transparent)",
74
- flexShrink: "0",
75
- });
76
- const name = document.createElement("span");
77
- Object.assign(name.style, {
78
- minWidth: "0",
79
- overflow: "hidden",
80
- textOverflow: "ellipsis",
81
- });
82
- button.append(avatar, name);
83
- const fallback = document.createElement("a");
84
- fallback.href = `${host}/account`;
85
- fallback.target = "_blank";
86
- fallback.rel = "noopener noreferrer";
87
- fallback.textContent = "Open Flow ID";
88
- fallback.hidden = true;
89
- let lastUser = null;
90
- let imageUrl = null;
91
- avatar.textContent = "F";
92
- let profileRequest = null;
93
- function update(refresh = false) {
94
- const user = flow.getState().user;
95
- name.textContent = user?.username ?? "Flow ID";
96
- button.setAttribute("aria-label", !user || user.isGuest
97
- ? "Sign in to Flow ID"
98
- : `Open ${user.username} Flow ID account in a new tab`);
99
- const identityChanged = user?.id !== lastUser?.id ||
100
- user?.username !== lastUser?.username ||
101
- user?.isGuest !== lastUser?.isGuest;
102
- if (identityChanged) {
103
- profileRequest?.abort();
104
- profileRequest = null;
105
- imageUrl = null;
106
- avatar.textContent = user?.username.slice(0, 1).toUpperCase() ?? "F";
107
- lastUser = user;
108
- }
109
- else if (!refresh || profileRequest) {
110
- // Same account: only an explicit refresh refetches, and never on top of
111
- // a request already in flight.
112
- return;
113
- }
114
- if (!user || user.isGuest)
115
- return;
116
- const controller = new AbortController();
117
- profileRequest = controller;
118
- void fetch(`${host}/api/users/${encodeURIComponent(user.username)}`, {
119
- signal: controller.signal,
120
- credentials: "omit",
121
- })
122
- .then(async (response) => {
123
- if (!response.ok)
124
- return;
125
- // SAFETY: Flow ID owns the public profile endpoint and its optional image field.
126
- const profile = (await response.json());
127
- if (controller.signal.aborted)
128
- return;
129
- if (profile.image === null) {
130
- imageUrl = null;
131
- avatar.textContent = user.username.slice(0, 1).toUpperCase();
132
- return;
133
- }
134
- if (!profile.image || profile.image === imageUrl)
135
- return;
136
- const image = document.createElement("img");
137
- image.src = profile.image;
138
- image.alt = "";
139
- Object.assign(image.style, {
140
- width: "100%",
141
- height: "100%",
142
- objectFit: "cover",
143
- });
144
- await image.decode();
145
- if (controller.signal.aborted)
146
- return;
147
- imageUrl = profile.image;
148
- avatar.replaceChildren(image);
149
- })
150
- .catch(() => { })
151
- .finally(() => {
152
- if (profileRequest === controller)
153
- profileRequest = null;
154
- });
21
+ document.body.appendChild(container);
155
22
  }
156
- button.addEventListener("click", () => {
157
- const user = flow.getState().user;
158
- if (!user || user.isGuest) {
159
- if (button.disabled)
160
- return;
161
- button.disabled = true;
162
- notice.textContent = "";
163
- fallback.hidden = true;
164
- void flow.login().catch(() => {
165
- button.disabled = false;
166
- notice.textContent = "Could not start sign-in. Try again.";
167
- });
23
+ const mount = document.createElement("div");
24
+ container.appendChild(mount);
25
+ let destroyed = false;
26
+ let theme = options.theme;
27
+ let renderer = null;
28
+ void import("../react/profile-renderer")
29
+ .then(({ mountProfileMenu }) => {
30
+ if (destroyed)
168
31
  return;
32
+ renderer = mountProfileMenu(mount, { flow, host: options.host, theme });
33
+ })
34
+ .catch(() => {
35
+ if (!destroyed) {
36
+ mount.setAttribute("role", "alert");
37
+ mount.textContent =
38
+ "Could not load the account menu. Reload and try again.";
169
39
  }
170
- const opened = flow.openAccount();
171
- notice.textContent = opened
172
- ? ""
173
- : "Your browser blocked the new tab. Open Flow ID below to sign in.";
174
- fallback.hidden = opened;
175
40
  });
176
- function refresh() {
177
- update(true);
178
- }
179
- window.addEventListener("focus", refresh);
180
- function setTheme(theme) {
181
- button.style.colorScheme = theme;
182
- }
183
- setTheme(options.theme ?? "light dark");
184
- container.append(button, notice, fallback);
185
- update();
186
- const unsubscribe = flow.subscribe(() => update());
187
41
  return {
188
- setTheme,
42
+ setTheme(value) {
43
+ theme = value;
44
+ renderer?.setTheme(value);
45
+ },
189
46
  destroy() {
190
- unsubscribe();
191
- profileRequest?.abort();
192
- window.removeEventListener("focus", refresh);
193
- fallback.remove();
194
- button.remove();
195
- notice.remove();
196
- createdContainer?.remove();
47
+ destroyed = true;
48
+ renderer?.destroy();
49
+ mount.remove();
50
+ if (!options.container)
51
+ container.remove();
197
52
  },
198
53
  };
199
54
  }
@@ -28,12 +28,12 @@ export async function restoreCredential(store) {
28
28
  */
29
29
  export async function runLogout(store) {
30
30
  const state = store.getSnapshot();
31
+ store.setState(initialFlowState);
31
32
  if (state.address) {
32
33
  const { clearAccessKey } = await import("./access-key");
33
34
  await clearAccessKey(state.address);
34
35
  }
35
36
  await idb.delete(CREDENTIAL_KEY);
36
- store.setState(initialFlowState);
37
37
  }
38
38
  /**
39
39
  * Derives the user's wallet address from their passkey public key by
@@ -45,6 +45,9 @@ export function createStaticFlow(state, options = {}) {
45
45
  },
46
46
  login: () => unavailable("login"),
47
47
  openAccount: () => unavailable("openAccount"),
48
+ switchAccounts: () => unavailable("switchAccounts"),
49
+ addAccount: () => unavailable("addAccount"),
50
+ listAccounts: () => unavailable("listAccounts"),
48
51
  logout: () => unavailable("logout"),
49
52
  restore: async () => false,
50
53
  ensureGuest: () => unavailable("ensureGuest"),
@@ -651,6 +651,173 @@ export declare const session: import("drizzle-orm/pg-core").PgTableWithColumns<{
651
651
  };
652
652
  dialect: "pg";
653
653
  }>;
654
+ export declare const browserAccountSet: import("drizzle-orm/pg-core").PgTableWithColumns<{
655
+ name: "browser_account_set";
656
+ schema: undefined;
657
+ columns: {
658
+ initialized: import("drizzle-orm/pg-core").PgColumn<{
659
+ name: "initialized";
660
+ tableName: "browser_account_set";
661
+ dataType: "boolean";
662
+ columnType: "PgBoolean";
663
+ data: boolean;
664
+ driverParam: boolean;
665
+ notNull: true;
666
+ hasDefault: true;
667
+ isPrimaryKey: false;
668
+ isAutoincrement: false;
669
+ hasRuntimeDefault: false;
670
+ enumValues: undefined;
671
+ baseColumn: never;
672
+ identity: undefined;
673
+ generated: undefined;
674
+ }, {}, {}>;
675
+ id: import("drizzle-orm/pg-core").PgColumn<{
676
+ name: "id";
677
+ tableName: "browser_account_set";
678
+ dataType: "string";
679
+ columnType: "PgText";
680
+ data: string;
681
+ driverParam: string;
682
+ notNull: true;
683
+ hasDefault: false;
684
+ isPrimaryKey: true;
685
+ isAutoincrement: false;
686
+ hasRuntimeDefault: false;
687
+ enumValues: [string, ...string[]];
688
+ baseColumn: never;
689
+ identity: undefined;
690
+ generated: undefined;
691
+ }, {}, {}>;
692
+ activeUserId: import("drizzle-orm/pg-core").PgColumn<{
693
+ name: "active_user_id";
694
+ tableName: "browser_account_set";
695
+ dataType: "string";
696
+ columnType: "PgText";
697
+ data: string;
698
+ driverParam: string;
699
+ notNull: false;
700
+ hasDefault: false;
701
+ isPrimaryKey: false;
702
+ isAutoincrement: false;
703
+ hasRuntimeDefault: false;
704
+ enumValues: [string, ...string[]];
705
+ baseColumn: never;
706
+ identity: undefined;
707
+ generated: undefined;
708
+ }, {}, {}>;
709
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
710
+ name: "updated_at";
711
+ tableName: "browser_account_set";
712
+ dataType: "date";
713
+ columnType: "PgTimestamp";
714
+ data: Date;
715
+ driverParam: string;
716
+ notNull: true;
717
+ hasDefault: true;
718
+ isPrimaryKey: false;
719
+ isAutoincrement: false;
720
+ hasRuntimeDefault: false;
721
+ enumValues: undefined;
722
+ baseColumn: never;
723
+ identity: undefined;
724
+ generated: undefined;
725
+ }, {}, {}>;
726
+ };
727
+ dialect: "pg";
728
+ }>;
729
+ export declare const browserAccountMember: import("drizzle-orm/pg-core").PgTableWithColumns<{
730
+ name: "browser_account_member";
731
+ schema: undefined;
732
+ columns: {
733
+ setId: import("drizzle-orm/pg-core").PgColumn<{
734
+ name: "set_id";
735
+ tableName: "browser_account_member";
736
+ dataType: "string";
737
+ columnType: "PgText";
738
+ data: string;
739
+ driverParam: string;
740
+ notNull: true;
741
+ hasDefault: false;
742
+ isPrimaryKey: false;
743
+ isAutoincrement: false;
744
+ hasRuntimeDefault: false;
745
+ enumValues: [string, ...string[]];
746
+ baseColumn: never;
747
+ identity: undefined;
748
+ generated: undefined;
749
+ }, {}, {}>;
750
+ userId: import("drizzle-orm/pg-core").PgColumn<{
751
+ name: "user_id";
752
+ tableName: "browser_account_member";
753
+ dataType: "string";
754
+ columnType: "PgText";
755
+ data: string;
756
+ driverParam: string;
757
+ notNull: true;
758
+ hasDefault: false;
759
+ isPrimaryKey: false;
760
+ isAutoincrement: false;
761
+ hasRuntimeDefault: false;
762
+ enumValues: [string, ...string[]];
763
+ baseColumn: never;
764
+ identity: undefined;
765
+ generated: undefined;
766
+ }, {}, {}>;
767
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
768
+ name: "session_id";
769
+ tableName: "browser_account_member";
770
+ dataType: "string";
771
+ columnType: "PgText";
772
+ data: string;
773
+ driverParam: string;
774
+ notNull: true;
775
+ hasDefault: false;
776
+ isPrimaryKey: false;
777
+ isAutoincrement: false;
778
+ hasRuntimeDefault: false;
779
+ enumValues: [string, ...string[]];
780
+ baseColumn: never;
781
+ identity: undefined;
782
+ generated: undefined;
783
+ }, {}, {}>;
784
+ userEpoch: import("drizzle-orm/pg-core").PgColumn<{
785
+ name: "user_epoch";
786
+ tableName: "browser_account_member";
787
+ dataType: "number";
788
+ columnType: "PgInteger";
789
+ data: number;
790
+ driverParam: string | number;
791
+ notNull: true;
792
+ hasDefault: false;
793
+ isPrimaryKey: false;
794
+ isAutoincrement: false;
795
+ hasRuntimeDefault: false;
796
+ enumValues: undefined;
797
+ baseColumn: never;
798
+ identity: undefined;
799
+ generated: undefined;
800
+ }, {}, {}>;
801
+ lastUsedAt: import("drizzle-orm/pg-core").PgColumn<{
802
+ name: "last_used_at";
803
+ tableName: "browser_account_member";
804
+ dataType: "date";
805
+ columnType: "PgTimestamp";
806
+ data: Date;
807
+ driverParam: string;
808
+ notNull: true;
809
+ hasDefault: true;
810
+ isPrimaryKey: false;
811
+ isAutoincrement: false;
812
+ hasRuntimeDefault: false;
813
+ enumValues: undefined;
814
+ baseColumn: never;
815
+ identity: undefined;
816
+ generated: undefined;
817
+ }, {}, {}>;
818
+ };
819
+ dialect: "pg";
820
+ }>;
654
821
  export declare const refreshToken: import("drizzle-orm/pg-core").PgTableWithColumns<{
655
822
  name: "refresh_token";
656
823
  schema: undefined;
@@ -79,6 +79,25 @@ export const session = pgTable("session", {
79
79
  createdAt: timestamp("created_at").notNull().defaultNow(),
80
80
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
81
81
  }, (table) => [index("session_userId_idx").on(table.userId)]);
82
+ export const browserAccountSet = pgTable("browser_account_set", {
83
+ initialized: boolean("initialized").notNull().default(false),
84
+ id: text("id").primaryKey(),
85
+ activeUserId: text("active_user_id"),
86
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
87
+ });
88
+ export const browserAccountMember = pgTable("browser_account_member", {
89
+ setId: text("set_id")
90
+ .notNull()
91
+ .references(() => browserAccountSet.id, { onDelete: "cascade" }),
92
+ userId: text("user_id")
93
+ .notNull()
94
+ .references(() => user.id, { onDelete: "cascade" }),
95
+ sessionId: text("session_id")
96
+ .notNull()
97
+ .references(() => session.id, { onDelete: "cascade" }),
98
+ userEpoch: integer("user_epoch").notNull(),
99
+ lastUsedAt: timestamp("last_used_at").notNull().defaultNow(),
100
+ }, (table) => [primaryKey({ columns: [table.setId, table.userId] })]);
82
101
  // First-party session persistence: consumer apps store the opaque token in their
83
102
  // OWN origin and mint access JWTs via POST /api/session/refresh — no third-party
84
103
  // id.flow.industries cookie, so it survives iOS ITP. Rotated on every use.