Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ the tables here list the same events; a test
| `account:profile_update` | | the profile form was submitted |
| `account:fio_update` | `is_active` | the profile form was submitted |
| `account:fio_link` | | FIO became active for a profile that had none |
| `account:fio_link_failed` | `reason` | saving FIO credentials was refused: `fio_required`, `fio_invalid_key` or `fio_username_mismatch` |
| `account:api_key_create` | | |

### Plan
Expand Down
7 changes: 5 additions & 2 deletions docs/features/fio.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ The backend proxies the data, and the user links FIO in their profile.

- FIO storage and sites live in `planningStore.fio_*` and are persisted.
They are loaded by the `GetFIOStorage` query (`callDataFIOStorage`),
which `NavigationBar` triggers on app load and `PlanConstructionCart`
triggers when needed.
which `NavigationBar` triggers whenever `userStore.fioStatus` is `ok` or
`error` (the states that can have stored data, so also when polling turns
`syncing` into `ok`) and `PlanConstructionCart` triggers when needed. In
every other state the nav drops the query cache, but the persisted
`planningStore.fio_*` stays until logout.
- The burn view calculates every plan with the planning engine
(`calculatePlan`, game data loaded once) first, then passes the results
into `useFIOBurn`.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

| Component | Does |
| --- | --- |
| `ChangeProfile.vue` | Edit profile fields (e.g. FIO username/key): `PatchUserProfile`, then `userStore.performGetProfile()` |
| `ChangeProfile.vue` | Edit profile fields (e.g. FIO username/key): `PatchUserProfile`, then `userStore.performGetProfile()`. The backend checks changed FIO credentials and answers 400 with a code per field (`fio_required`, `fio_invalid_key`, `fio_username_mismatch`), shown under the field; the FIO status line reads `userStore.fioStatus` (the same state drives the nav FIO tag) |
| `ChangePassword.vue` | `PatchUserChangePassword` |
| `FIOInformation.vue` | Explains how to link FIO (static, i18n `profile.fio_information.*`) |
| `UserPreferences.vue` | Edits global preferences through `usePreferences`: default empire and CX (`CXPreferenceSelector`), burn thresholds, XIT defaults, and so on |
Expand Down
12 changes: 12 additions & 0 deletions src/features/api/schemas/user.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export const RefreshTokenResponseSchema = TokenResponseSchema.pick({
});
export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>;

export const FIOStatusSchema = z.enum([
"none",
"syncing",
"ok",
"no_data",
"invalid_credentials",
"error",
]);
export type FIOStatus = z.infer<typeof FIOStatusSchema>;

export const UserProfileSchema = z.object({
id: z.number(),
username: z.string(),
Expand All @@ -36,6 +46,8 @@ export const UserProfileSchema = z.object({
.string()
.transform((val) => (val === "" ? null : val))
.nullable(),
fio_status: FIOStatusSchema,
fio_last_refreshed_at: z.string().nullable(),
});
export type UserProfile = z.infer<typeof UserProfileSchema>;

Expand Down
80 changes: 79 additions & 1 deletion src/features/profile/components/ChangeProfile.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, watch, type Ref, ref, onMounted } from "vue";
import { computed, reactive, watch, type Ref, ref, onMounted } from "vue";

import { useI18n } from "vue-i18n";
const { t } = useI18n();
Expand All @@ -13,6 +13,9 @@
// Stores
import { useUserStore } from "@/stores/userStore";

// Util
import { relativeFromDate } from "@/util/date";

// UI
import {
PForm,
Expand All @@ -31,6 +34,36 @@
const wasSaved: Ref<boolean> = ref(true);
const codeResendRequested: Ref<boolean> = ref(false);

// backend error codes per FIO field, shown under the field
type FIOField = "fio_apikey" | "prun_username";
const KNOWN_ERRORS = [
"fio_required",
"fio_invalid_key",
"fio_username_mismatch",
];
const fioErrors: Ref<Partial<Record<FIOField, string>>> = ref({});
const fioConnected: Ref<boolean> = ref(false);
// until the first refresh replaces it with the real status
const showConnected = computed(
() => fioConnected.value && userStore.fioStatus === "syncing"
);

const fioStatusText = computed(() =>
t(`profile.change_profile.fio_status.${userStore.fioStatus}`, {
time: relativeFromDate(
userStore.profile?.fio_last_refreshed_at
? new Date(userStore.profile.fio_last_refreshed_at)
: undefined
),
})
);

function errorText(code: string): string {
return t(
`profile.change_profile.fio_errors.${KNOWN_ERRORS.includes(code) ? code : "unknown"}`
);
}

watch(
() => userStore.profile,
(newProfile) => Object.assign(localProfile, newProfile)
Expand Down Expand Up @@ -58,8 +91,14 @@
);

trackEvent("account:fio_update", { is_active: userHasFIO });
const fioChanged: boolean =
fioApiKey !== (userStore.profile?.fio_apikey ?? null) ||
(localProfile.prun_username ?? null) !==
(userStore.profile?.prun_username ?? null);

isUpdating.value = true;
fioErrors.value = {};
fioConnected.value = false;

try {
await useQuery("PatchUserProfile", {
Expand All @@ -69,10 +108,21 @@
}).execute();

if (userHasFIO && !userHadFIO) trackEvent("account:fio_link");
fioConnected.value = userHasFIO && fioChanged;

wasSaved.value = true;
} catch (err) {
console.error("Error patching user profile", err);
// DRF field errors: { field: [code] }
const data = (err as { responseData?: Record<string, unknown> })
.responseData;
for (const field of ["fio_apikey", "prun_username"] as const) {
const messages = data?.[field];
if (Array.isArray(messages) && typeof messages[0] === "string")
fioErrors.value[field] = messages[0];
}
const reason = fioErrors.value.fio_apikey ?? fioErrors.value.prun_username;
if (reason) trackEvent("account:fio_link_failed", { reason });
} finally {
isUpdating.value = false;
}
Expand Down Expand Up @@ -113,11 +163,39 @@
<PInput
v-model:value="localProfile.fio_apikey"
class="w-full min-w-50 max-w-[50%]" />
<template v-if="fioErrors.fio_apikey" #info>
<span class="text-negative" role="alert">
{{ errorText(fioErrors.fio_apikey) }}
</span>
</template>
</PFormItem>
<PFormItem :label="t('profile.change_profile.form.prun_username')">
<PInput
v-model:value="localProfile.prun_username"
class="w-full min-w-50 max-w-[50%]" />
<template v-if="fioErrors.prun_username" #info>
<span class="text-negative" role="alert">
{{ errorText(fioErrors.prun_username) }}
</span>
</template>
</PFormItem>
<PFormItem :label="t('profile.change_profile.form.fio_status')">
<div
class="text-sm"
:class="{
'text-positive': showConnected,
'text-negative':
!showConnected &&
userStore.fioStatus === 'invalid_credentials',
}"
role="status"
data-testid="fio-status">
{{
showConnected
? t("profile.change_profile.fio_connected")
: fioStatusText
}}
</div>
</PFormItem>
<PFormSeperator>
<div class="py-3 text-white/60">
Expand Down
37 changes: 30 additions & 7 deletions src/layout/components/NavigationBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

// Types & Interfaces
import type { IMenuSection } from "@/layout/components/navigation.types";
import type { FIOStatus } from "@/features/api/schemas/user.schemas";
import type { ColorKey } from "@/ui/ui.types";

// UI
import { PTag, PTooltip, PTable, PIcon } from "@/ui";
Expand Down Expand Up @@ -60,10 +62,16 @@
*/

watch(
() => userStore.hasFIO,
// only these states can have stored FIO data; asking in the others is a 404
() => userStore.hasFIO && ["ok", "error"].includes(userStore.fioStatus),
(newValue: boolean) => {
if (newValue) {
useQuery("GetFIOStorage").execute();
// still 404 for an error before any FIO data arrived
useQuery("GetFIOStorage")
.execute()
.catch((err: { status?: number }) => {
if (err.status !== 404) throw err;
});
} else {
queryStore.invalidateKey(["gamedata", "fio"], {
exact: false,
Expand All @@ -82,6 +90,21 @@

const storageAge = computed(() => planningStore.fio_storage_timestamp ?? 0);

// every state but ok links to the profile, where the status is explained
const FIO_TAGS: Record<
Exclude<FIOStatus, "ok">,
{ label: string; type: ColorKey }
> = {
none: { label: "fio_inactive", type: "warning" },
error: { label: "fio_inactive", type: "warning" },
syncing: { label: "fio_syncing", type: "secondary" },
no_data: { label: "fio_no_data", type: "warning" },
invalid_credentials: { label: "fio_rejected", type: "error" },
};
const fioTag = computed(() =>
userStore.fioStatus === "ok" ? undefined : FIO_TAGS[userStore.fioStatus]
);

const menuItems: ComputedRef<IMenuSection[]> = computed(() => [
{
label: t("common.navigation.planning"),
Expand Down Expand Up @@ -541,7 +564,7 @@
class="flex gap-1 justify-between items-center"
:class="isFull ? 'flex-row' : 'flex-col'">
<div>
<PTooltip v-if="userStore.hasFIO && storageTimestamp !== 0">
<PTooltip v-if="!fioTag">
<template #trigger>
<PTag size="sm" type="success" :bordered="false">
{{
Expand All @@ -567,10 +590,10 @@
<tbody>
<tr>
<td>
{{ relativeFromDate(storageTimestamp) }}
{{ relativeFromDate(storageTimestamp || undefined) }}
</td>
<td>
{{ relativeFromDate(storageAge) }}
{{ relativeFromDate(storageAge || undefined) }}
</td>
</tr>
</tbody>
Expand All @@ -580,10 +603,10 @@
v-else
to="/profile"
class="inline-flex items-center min-h-6">
<PTag size="sm" type="warning" :bordered="false">
<PTag size="sm" :type="fioTag.type" :bordered="false">
{{
isFull
? t("common.navigation.fio.fio_inactive")
? t(`common.navigation.fio.${fioTag.label}`)
: t("common.navigation.fio.fio")
}}
</PTag>
Expand Down
1 change: 1 addition & 0 deletions src/lib/analytics/useAnalytics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface IAnalyticsEventProperties {
"account:profile_update": undefined;
"account:fio_update": { is_active: boolean };
"account:fio_link": undefined;
"account:fio_link_failed": { reason: string };
"account:api_key_create": undefined;

"plan:view": { planet_natural_id: string | undefined; is_shared: boolean };
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en_US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
"backend": "Backend",
"fio": "FIO",
"fio_active": "FIO Active",
"fio_inactive": "FIO Inactive"
"fio_inactive": "FIO Inactive",
"fio_syncing": "FIO Syncing",
"fio_no_data": "FIO Waiting for Data",
"fio_rejected": "FIO Key Rejected"
},
"collapse": "Collapse navigation",
"expand": "Expand navigation",
Expand Down
18 changes: 17 additions & 1 deletion src/locales/en_US/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,23 @@
"prun_username": "PrUn Username",
"email_info": "It is not required to provide an email but highly recommended for increased account safety and password recovery.",
"email_address": "Email Address",
"email_verified": "Email Verified"
"email_verified": "Email Verified",
"fio_status": "FIO Status"
},
"fio_connected": "Connected. Your data will appear in a few minutes.",
"fio_status": {
"none": "Not connected. Enter your FIO API key and PrUn username.",
"syncing": "Connected, loading your FIO data.",
"ok": "Connected. Last FIO update {time}.",
"no_data": "Connected, but FIO has no data for you yet. Open Prosperous Universe with the FIO extension running.",
"invalid_credentials": "FIO rejected your API key. Enter a valid key and save again.",
"error": "FIO updates are failing right now. Check your FIO key and username."
},
"fio_errors": {
"fio_required": "Enter both the FIO API key and the PrUn username.",
"fio_invalid_key": "FIO doesn't accept this API key.",
"fio_username_mismatch": "This API key belongs to a different PrUn username.",
"unknown": "Saving failed. Please try again."
}
},
"storage_data": {
Expand Down
36 changes: 36 additions & 0 deletions src/stores/userStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
reactive,
ref,
type Ref,
watch,
} from "vue";
import merge from "lodash/merge";

Expand Down Expand Up @@ -36,6 +37,7 @@ import type {
RefreshTokenResponse,
TokenResponse,
UserPreference,
FIOStatus,
UserProfile,
} from "@/features/api/schemas/user.schemas";
import { preferenceDefaults } from "@/features/preferences/userDefaults";
Expand All @@ -47,6 +49,9 @@ import {
type SupportedLocale,
} from "@/lib/i18n";

const FIO_POLL_MS = 15_000;
const FIO_POLL_TRIES = 20;

export const useUserStore = defineStore(
"prunplanner_user",
() => {
Expand Down Expand Up @@ -181,6 +186,11 @@ export const useUserStore = defineStore(
refreshToken.value !== undefined
);

// a profile persisted before fio_status existed has none until it reloads
const fioStatus: ComputedRef<FIOStatus> = computed(
() => profile.value?.fio_status ?? "none"
);

const hasFIO: ComputedRef<boolean> = computed(
() =>
profile.value !== undefined &&
Expand Down Expand Up @@ -336,6 +346,31 @@ export const useUserStore = defineStore(
}
}

/*
* New FIO credentials read "syncing" until the worker's first refresh,
* usually seconds: reload the profile until the status moves on, for at
* most FIO_POLL_TRIES * FIO_POLL_MS
*/
let fioPoll: ReturnType<typeof setTimeout> | undefined;
watch(
fioStatus,
(status) => {
clearTimeout(fioPoll);
if (status !== "syncing") return;

let tries = 0;
const poll = () => {
fioPoll = setTimeout(async () => {
await performGetProfile();
if (fioStatus.value === "syncing" && ++tries < FIO_POLL_TRIES)
poll();
}, FIO_POLL_MS);
};
poll();
},
{ immediate: true }
);

return {
accessToken,
refreshToken,
Expand All @@ -345,6 +380,7 @@ export const useUserStore = defineStore(
// getters
isLoggedIn,
hasFIO,
fioStatus,
// preferences
intialPreferencesCalled,
preferences,
Expand Down
Loading
Loading