@mundogamernetwork/shared-ui 1.8.16 → 1.8.17

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,5 @@
1
1
  import axios, { type AxiosInstance } from "axios"
2
+ import { clearAuthCredentials } from "../utils/clearSession"
2
3
 
3
4
  // Campaign service wraps the key-campaigns API used by TV, community, and agency.
4
5
  // Each frontend backend registers the shared key routes through its own provider.
@@ -8,6 +9,29 @@ import axios, { type AxiosInstance } from "axios"
8
9
  let _mainClient: AxiosInstance | null = null
9
10
  let _authClient: AxiosInstance | null = null
10
11
 
12
+ // Debounce flag so a burst of failing requests only triggers one recovery.
13
+ let _sessionLossHandled = false
14
+
15
+ /**
16
+ * Whether the client currently presents the user as logged in. Read from the
17
+ * auth store, falling back to the persisted copy the store hydrates from — the
18
+ * whole problem being solved here is that this can be true while the server no
19
+ * longer accepts the session.
20
+ */
21
+ function clientThinksItIsSignedIn(): boolean {
22
+ try {
23
+ const authStore = useAuthStore() as any
24
+ if (typeof authStore?.signedIn === "boolean") return authStore.signedIn
25
+ } catch (_) {
26
+ // store not available in this context — fall through to localStorage
27
+ }
28
+ try {
29
+ return !!JSON.parse(localStorage.getItem("auth-store") || "{}")?.signedIn
30
+ } catch (_) {
31
+ return false
32
+ }
33
+ }
34
+
11
35
  const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "de", "ro", "ar-AE"]
12
36
 
13
37
  function resolveLang(): string {
@@ -104,9 +128,59 @@ function getAuthClient(): AxiosInstance {
104
128
  return config
105
129
  })
106
130
 
131
+ // Session-loss handling. The auth store persists `signedIn`/`user` in
132
+ // localStorage, so the UI keeps rendering a logged-in user (and every
133
+ // key-campaign CTA stays enabled) even after the httpOnly `oauth_token`
134
+ // cookie is gone or no longer accepted. The first thing the user notices is
135
+ // a raw backend "Unauthorized" under the submit button. Clearing the
136
+ // credentials here puts the client back into a real guest state so the page
137
+ // can offer a re-login instead of an untranslated dead end.
138
+ _authClient.interceptors.response.use(
139
+ (response) => response,
140
+ (error) => {
141
+ const status = error?.response?.status
142
+ if (typeof window !== "undefined" && (status === 401 || status === 419)) {
143
+ error.isSessionExpired = true
144
+ // Only tear down a session that the client still believes in. A
145
+ // guest browsing the campaign list also gets 401s from the
146
+ // auth-only endpoints on this client (campaign types, my
147
+ // requests), and running the whole recovery — cookie wipe plus a
148
+ // POST /logout — on every one of those is pure noise.
149
+ if (!_sessionLossHandled && clientThinksItIsSignedIn()) {
150
+ _sessionLossHandled = true
151
+ try {
152
+ clearAuthCredentials()
153
+ } catch (_) {
154
+ // store/runtime config not available in this context
155
+ }
156
+ // Allow another recovery later (covers a genuinely new failure)
157
+ setTimeout(() => {
158
+ _sessionLossHandled = false
159
+ }, 10000)
160
+ }
161
+ }
162
+ return Promise.reject(error)
163
+ },
164
+ )
165
+
107
166
  return _authClient
108
167
  }
109
168
 
169
+ /** True when a caught error came back as an expired/rejected session. */
170
+ export function isSessionExpired(error: any): boolean {
171
+ const status = error?.response?.status
172
+ return error?.isSessionExpired === true || status === 401 || status === 419
173
+ }
174
+
175
+ /**
176
+ * Login URL that returns the user to the page they were on. `loginUrl` is the
177
+ * per-app value injected by TV/community/agency.
178
+ */
179
+ export function buildReLoginUrl(loginUrl: string): string {
180
+ const returnTo = typeof window !== "undefined" ? window.location.href : ""
181
+ return `${loginUrl}?redirect_to=${encodeURIComponent(returnTo)}`
182
+ }
183
+
110
184
  // Lazy proxies so the clients are only initialised when first used
111
185
  const mainClient = new Proxy({} as AxiosInstance, {
112
186
  get(_t, prop) {
@@ -332,6 +406,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
332
406
  press_kit_link: k.press_kit_link ?? null,
333
407
  review_guideline: k.review_guideline ?? null,
334
408
  embargo_date: k.embargo_date ?? null,
409
+ embargo_timezone: k.embargo_timezone ?? null,
410
+ embargo_active: k.embargo_active ?? false,
335
411
  request_id: k.requests?.[0]?.id,
336
412
  request_item_id: k.requests?.[0]?.key_item_id,
337
413
  request_admin_notes: k.requests?.[0]?.admin_notes ?? null,
@@ -74,6 +74,28 @@ export function clearAuthCredentials() {
74
74
  } catch (_) {
75
75
  // store not available in this context
76
76
  }
77
+
78
+ // The main session cookie (oauth_token) is httpOnly — the expireCookie()
79
+ // loop above can never touch it (that's the whole point of httpOnly, XSS
80
+ // protection), so a stale/invalid token kept being resent on every
81
+ // request and the user stayed stuck in a 401 loop until a MANUAL logout,
82
+ // which works only because it round-trips through the server and gets a
83
+ // real Set-Cookie expiry back. Fire that same call here, best-effort:
84
+ // even if it fails (network hiccup, cross-domain Set-Cookie stripped by
85
+ // a CDN — a known risk, see file docblock above), the cookie-loop +
86
+ // authStore.clearUser() already got the client into a clean guest state.
87
+ try {
88
+ const runtimeConfig = useRuntimeConfig();
89
+ const apiBaseURL = runtimeConfig.public.mgSharedUi?.apiBaseURL || runtimeConfig.public.apiBaseURL;
90
+ if (apiBaseURL) {
91
+ fetch(`${String(apiBaseURL).replace(/\/$/, "")}/logout`, {
92
+ method: "POST",
93
+ credentials: "include",
94
+ }).catch(() => {});
95
+ }
96
+ } catch (_) {
97
+ // runtime config not available in this context (e.g. outside a Nuxt app)
98
+ }
77
99
  }
78
100
 
79
101
  /**
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Embargo formatting for key campaigns.
3
+ *
4
+ * An embargo is a day AND a time: "August 12" alone is not something a creator
5
+ * can comply with — publishing at 09:00 or at 23:00 on that day are very
6
+ * different acts. The API stores `embargo_date` as an instant (UTC) plus the
7
+ * `embargo_timezone` the campaign creator confirmed it in.
8
+ *
9
+ * Both matter to the creator, so both are shown: the campaign's own wall-clock
10
+ * (how the studio communicates the embargo everywhere else) and, when the
11
+ * viewer is somewhere else, the same instant on their own clock — so nobody has
12
+ * to do timezone arithmetic to know when they may publish.
13
+ */
14
+
15
+ /** True while the embargo is set and has not lifted yet. */
16
+ export function isEmbargoActive(dateString: string | null | undefined): boolean {
17
+ if (!dateString) return false
18
+ const date = new Date(dateString)
19
+ return !isNaN(date.getTime()) && date.getTime() > Date.now()
20
+ }
21
+
22
+ /**
23
+ * How long until the embargo lifts, localized: "em 12 dias", "amanhã",
24
+ * "em 5 horas". Creators request keys days before release, so a bare date is
25
+ * easy to lose track of — the countdown is what actually sticks.
26
+ *
27
+ * Returns "" once the embargo has lifted (or when there is none).
28
+ */
29
+ export function formatEmbargoCountdown(
30
+ dateString: string | null | undefined,
31
+ locale: string,
32
+ ): string {
33
+ if (!isEmbargoActive(dateString)) return ""
34
+ const diffMs = new Date(dateString as string).getTime() - Date.now()
35
+
36
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
37
+ const minutes = Math.round(diffMs / 60000)
38
+ if (minutes < 60) return rtf.format(Math.max(minutes, 1), "minute")
39
+
40
+ const hours = Math.round(diffMs / 3600000)
41
+ if (hours < 24) return rtf.format(hours, "hour")
42
+
43
+ return rtf.format(Math.round(diffMs / 86400000), "day")
44
+ }
45
+
46
+ function format(
47
+ date: Date,
48
+ locale: string,
49
+ timeZone?: string,
50
+ timeZoneName: "short" | "long" = "short",
51
+ ): string {
52
+ return new Intl.DateTimeFormat(locale, {
53
+ day: "numeric",
54
+ month: "long",
55
+ year: "numeric",
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ timeZoneName,
59
+ ...(timeZone ? { timeZone } : {}),
60
+ }).format(date)
61
+ }
62
+
63
+ /** Time only — used for the local equivalent when it lands on the same day. */
64
+ function formatTime(date: Date, locale: string, timeZone?: string): string {
65
+ return new Intl.DateTimeFormat(locale, {
66
+ hour: "2-digit",
67
+ minute: "2-digit",
68
+ timeZoneName: "short",
69
+ ...(timeZone ? { timeZone } : {}),
70
+ }).format(date)
71
+ }
72
+
73
+ /** Calendar day in a given zone, as a comparable "en-CA" YYYY-MM-DD string. */
74
+ function dayIn(date: Date, timeZone?: string): string {
75
+ return new Intl.DateTimeFormat("en-CA", {
76
+ year: "numeric",
77
+ month: "2-digit",
78
+ day: "2-digit",
79
+ ...(timeZone ? { timeZone } : {}),
80
+ }).format(date)
81
+ }
82
+
83
+ /**
84
+ * Full embargo moment: the campaign's own wall-clock, named in the viewer's
85
+ * language, plus the same instant on the viewer's clock when they differ.
86
+ * e.g. "12 de agosto de 2026 às 14:00 Horário Padrão de Brasília — 19:00 GMT+2"
87
+ *
88
+ * Returns "" for a missing/invalid date so callers can `v-if` on it.
89
+ */
90
+ export function formatEmbargoDateTime(
91
+ dateString: string | null | undefined,
92
+ timezone: string | null | undefined,
93
+ locale: string,
94
+ ): string {
95
+ if (!dateString) return ""
96
+ const date = new Date(dateString)
97
+ if (isNaN(date.getTime())) return ""
98
+
99
+ const viewerZone = Intl.DateTimeFormat().resolvedOptions().timeZone
100
+
101
+ if (!timezone || timezone === viewerZone) {
102
+ return format(date, locale)
103
+ }
104
+
105
+ try {
106
+ // "long" names the zone in the viewer's own language ("Horário Padrão de
107
+ // Brasília", "Ora standard a Brasiliei"). Deriving a label from the IANA
108
+ // id instead would print ASCII city names ("Sao Paulo", no tilde) in
109
+ // every language.
110
+ const campaign = format(date, locale, timezone, "long")
111
+ // Two zones on the same offset right now — the local line would be a
112
+ // verbatim repeat and just look like a bug.
113
+ if (formatTime(date, locale, timezone) === formatTime(date, locale)) {
114
+ return campaign
115
+ }
116
+ const local = dayIn(date, timezone) === dayIn(date)
117
+ ? formatTime(date, locale)
118
+ : format(date, locale)
119
+ return `${campaign} — ${local}`
120
+ } catch {
121
+ // Unknown/invalid IANA zone recorded on the campaign: never let the
122
+ // embargo line disappear because of it.
123
+ return format(date, locale)
124
+ }
125
+ }