login time

This commit is contained in:
Sone
2025-11-05 20:11:34 +01:00
parent b43aec6b9a
commit fdcc42007a
21 changed files with 1363 additions and 843 deletions
+22 -17
View File
@@ -1,6 +1,6 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import { View, Text } from "react-native";
import { View, Text, TouchableOpacity } from "react-native";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -8,10 +8,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { AuthLayout } from "@/components/auth/AuthLayout";
import { AuthHeader } from "@/components/auth/AuthHeader";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthFooter } from "@/components/auth/AuthFooter";
import { FormInput } from "@/components/auth/FormInput";
import { getApiUrl } from "@/store/api/baseApi";
import { AuthButton } from "@/components/auth/AuthButton";
import { MessageDisplay } from "@/components/auth/MessageDisplay";
const schema = z.object({
email: z.email("Enter a valid email"),
@@ -72,16 +72,12 @@ const ForgotPasswordScreen = () => {
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthHeader title="Bird Eye View" />
<AuthCard title="Forgot Password" subtitle="We'll email you a reset link">
{!!error && (
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
)}
{!!message && (
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
)}
{error ? <MessageDisplay message={error} type="error" /> : null}
{message ? <MessageDisplay message={message} type="success" /> : null}
<FormInput
label={errors.email?.message || "Email"}
label="Email"
value={email}
onChangeText={(t) => setValue("email", t, { shouldValidate: true })}
icon="email"
@@ -90,7 +86,11 @@ const ForgotPasswordScreen = () => {
returnKeyType="send"
submitBehavior="blurAndSubmit"
/>
<View style={{ marginTop: 8 }}>
{errors.email?.message ? (
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
{errors.email.message}
</Text>
) : null}
<AuthButton
onPress={handleSubmit(onSubmit)}
loading={submitting}
@@ -98,14 +98,19 @@ const ForgotPasswordScreen = () => {
>
{submitting ? "Sending..." : "Send Reset Link"}
</AuthButton>
</View>
</AuthCard>
<AuthFooter
question="Remembered your password?"
actionText="Sign in"
<View style={{ alignItems: "center", marginTop: 16 }}>
<Text>Remembered your password?</Text>
<TouchableOpacity
onPress={() => router.push("/sign-in")}
disabled={submitting}
/>
>
<Text className="text-primaryPurpleDark font-bold mt-4">
Sign in
</Text>
</TouchableOpacity>
</View>
</AuthCard>
</AuthLayout>
);
};
+1 -1
View File
@@ -84,7 +84,7 @@ const ResetPasswordScreen = () => {
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthHeader title="Bird Eye View" />
<AuthCard
title="Reset Password"
subtitle={
+3 -7
View File
@@ -30,7 +30,6 @@ const SignIn = () => {
useWarmUpBrowser();
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const successMessage = "";
const isResettingPassword = false;
@@ -43,15 +42,12 @@ const SignIn = () => {
password: string;
}) => {
setErrorMessage("");
setIsLoading(true);
try {
await login(vals.emailAddress, vals.password);
router.replace("/");
} catch (err: any) {
const msg = err?.message || "Invalid credentials";
setErrorMessage(msg);
} finally {
setIsLoading(false);
}
};
@@ -63,20 +59,20 @@ const SignIn = () => {
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthHeader title="Bird Eye View" />
<AuthCard title="Welcome Back" subtitle="Sign in to continue">
<SignInForm
errorMessage={errorMessage}
successMessage={successMessage}
isLoading={isLoading}
isLoading={loading}
onSignInSubmit={onSignInSubmit}
/>
<ForgotPasswordLink
onPress={onForgotPasswordPress}
isResetting={isResettingPassword}
disabled={isResettingPassword || isLoading}
disabled={isResettingPassword || loading}
/>
</AuthCard>
</AuthLayout>
+26 -6
View File
@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useState,
useRef,
} from "react";
import * as WebBrowser from "expo-web-browser";
@@ -47,6 +48,8 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
const [tokenState, setTokenState] = useState<string | null>(getToken());
const [bootstrapping, setBootstrapping] = useState(true);
const [loading, setLoading] = useState(false);
const loginInFlightRef = useRef(false);
const loginPromiseRef = useRef<Promise<void> | null>(null);
const isAuthenticated = !!tokenState && !!user;
@@ -79,7 +82,14 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
}, []);
const login: AuthContextType["login"] = async (email, password) => {
if (loginInFlightRef.current && loginPromiseRef.current) {
return await loginPromiseRef.current;
}
loginInFlightRef.current = true;
setLoading(true);
const p = (async () => {
try {
const res = await fetch(`${getApiUrl()?.replace(/\/$/, "")}/auth/login`, {
method: "POST",
@@ -89,16 +99,20 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
if (!res.ok) {
const status = res.status;
let msg = "Invalid credentials";
let serverMsg: string | undefined;
try {
const errData = await res.json();
msg = errData?.message || errData?.error || msg;
} catch {
// non-JSON response, keep default msg
}
// Only replace the message for explicit 'not activated' cases
if (status === 403 && /not activated/i.test(msg)) {
serverMsg = errData?.message || errData?.error;
} catch {}
if (status === 403 && serverMsg && /not activated/i.test(serverMsg)) {
msg =
"Account is not activated. Please use your invitation link to set a password.";
} else if (status >= 400 && status < 500) {
msg = serverMsg || msg;
} else {
// For 5xx, keep default 'Invalid credentials' to avoid leaking internals
msg = "Invalid credentials";
}
throw new Error(msg);
}
@@ -111,7 +125,13 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
store.dispatch(baseApi.util.invalidateTags(["Users", "Todos"]));
} finally {
setLoading(false);
loginInFlightRef.current = false;
loginPromiseRef.current = null;
}
})();
loginPromiseRef.current = p;
return await p;
};
const register: AuthContextType["register"] = async ({
+1 -1
View File
@@ -4,7 +4,7 @@ interface AuthHeaderProps {
title?: string;
}
export const AuthHeader = ({ title = "Project Athena" }: AuthHeaderProps) => {
export const AuthHeader = ({ title = "Bird Eye View" }: AuthHeaderProps) => {
return (
<View style={{ alignItems: "center", marginBottom: 36 }}>
<Image
+1 -1
View File
@@ -17,7 +17,7 @@ export const ForgotPasswordLink = ({
onPress={onPress}
disabled={disabled || isResetting}
>
<Text className="text-primaryPurple">
<Text className="text-primaryPurpleDark font-bold">
{isResetting ? "Sending reset email..." : "Forgot Password?"}
</Text>
</TouchableOpacity>
+17 -3
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import {
Platform,
NativeSyntheticEvent,
@@ -32,6 +32,20 @@ export const PasswordInput = ({
submitBehavior,
}: PasswordInputProps) => {
const [secureTextEntry, setSecureTextEntry] = useState(true);
const submitGuardRef = useRef(false);
const triggerSubmitOnce = () => {
if (submitGuardRef.current) return;
submitGuardRef.current = true;
try {
onSubmitEditing?.();
} finally {
// release guard on next tick to allow future submissions
setTimeout(() => {
submitGuardRef.current = false;
}, 0);
}
};
return (
<TextInput
@@ -46,12 +60,12 @@ export const PasswordInput = ({
backgroundColor: "transparent",
...style,
}}
onSubmitEditing={onSubmitEditing}
onSubmitEditing={() => triggerSubmitOnce()}
returnKeyType={returnKeyType}
submitBehavior={submitBehavior ?? "submit"}
onKeyPress={(e) => {
if (Platform.OS === "web" && e?.nativeEvent?.key === "Enter") {
onSubmitEditing?.();
triggerSubmitOnce();
}
}}
left={<TextInput.Icon icon="lock" color="#6750a4" />}
+2 -3
View File
@@ -71,7 +71,7 @@ export const SignInForm = ({
returnKeyType="next"
/>
{error ? (
<Text style={{ color: "#F83434", marginBottom: 8 }}>
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
{error.message}
</Text>
) : null}
@@ -92,11 +92,10 @@ export const SignInForm = ({
disabled={isLoading}
style={{ marginBottom: 8 }}
returnKeyType="done"
submitBehavior="blurAndSubmit"
onSubmitEditing={() => handleSubmit(submit)()}
/>
{error ? (
<Text style={{ color: "#F83434", marginBottom: 8 }}>
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
{error.message}
</Text>
) : null}
@@ -0,0 +1,39 @@
import { View, Text } from "react-native";
import { UserLoginLog } from "@/store/api/usersApi";
import { formatDate, formatTimeWithSeconds } from "@/utils/dateUtils";
import { reasonLabel } from "@/utils/loginUtils";
interface LoginListItemProps {
log: UserLoginLog;
}
export const LoginListItem = ({ log }: LoginListItemProps) => {
const isSuccess = log.status === "SUCCESS";
const isFailure = log.status === "FAILURE";
return (
<View className="bg-white rounded-lg p-1 mb-3 shadow-sm">
<View className="flex-row items-center">
<Text
className="text-sm font-medium text-gray-700 flex-1"
numberOfLines={1}
ellipsizeMode="tail"
>
{formatDate(log.lastLoginAt)} -{" "}
{formatTimeWithSeconds(log.lastLoginAt)} {" | "}
<Text
style={{
color: isSuccess ? "#22C927" : "#F83434",
fontWeight: "bold",
}}
>
{isSuccess
? log.activity
: reasonLabel(log.failureReason) || "Failed"}
</Text>
</Text>
</View>
</View>
);
};
@@ -1,16 +1,37 @@
import { View, Text } from "react-native";
import { User } from "@/store/models/User.model";
import { useGetUserLogsByIdQuery } from "@/store/api/usersApi";
import { LoginListItem } from "@/components/features/accounts/LoginListItem";
interface PersonalLoginListProps {
user: User;
}
export const PersonalLoginList = ({ user }: PersonalLoginListProps) => {
const { data, isLoading, isError } = useGetUserLogsByIdQuery(
{ userId: user.id, page: 1, limit: 10 },
{ skip: !user?.id }
);
const items = data?.items ?? [];
return (
<View className="bg-lightGray rounded-md">
<Text className="font-medium my-4 text-gray-600">Log-in List:</Text>
<Text className="font-medium text-black">No logins assigned</Text>
<Text className="font-medium mb-4 mt-0 text-gray-600">Log-in List:</Text>
{isLoading ? (
<Text className="text-black">Loading...</Text>
) : isError ? (
<Text className="text-black">-</Text>
) : items.length === 0 ? (
<Text className="font-medium text-black">No logins history</Text>
) : (
<View style={{ gap: 8 }}>
{items.map((log, idx) => (
<LoginListItem key={`${log.id}-${idx}`} log={log} />
))}
</View>
)}
</View>
);
};
@@ -5,7 +5,7 @@ import { InfoRow } from "./InfoRow";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { useGetUserLogsQuery } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
import { formatDate, formatTimeWithSeconds } from "@/utils/dateUtils";
import { RiskIndicator } from "@/components/ui/RiskIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
@@ -35,7 +35,7 @@ export const PersonalMetrics = ({ user }: PersonalMetricsProps) => {
: logsError
? "-"
: lastLoginAt
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
? `${formatDate(lastLoginAt)} ${formatTimeWithSeconds(lastLoginAt)}`
: "-";
const lastPasswordChangeDisplay = logsLoading
@@ -43,7 +43,7 @@ export const PersonalMetrics = ({ user }: PersonalMetricsProps) => {
: logsError
? "-"
: activity === "Password reset" && lastLoginAt
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
? `${formatDate(lastLoginAt)} ${formatTimeWithSeconds(lastLoginAt)}`
: "-";
return (
@@ -23,7 +23,7 @@ export const PersonalTodoList = ({ user }: PersonalTodoListProps) => {
return (
<View className="bg-lightGray rounded-md">
<Text className="font-medium my-4 text-gray-600">To-Do List:</Text>
<Text className="font-medium mt-0 mb-4 text-gray-600">To-Do List:</Text>
{isLoading ? (
<LoadingSpinner />
) : error ? (
+12 -3
View File
@@ -2,9 +2,10 @@ import { DataTable, Text } from "react-native-paper";
import { View } from "react-native";
import { UserLoginLog } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
import { formatDate, formatTimeWithSeconds } from "@/utils/dateUtils";
import { LogsTableHeader } from "@/components/features/logs/LogsTableHeader";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { reasonLabel } from "@/utils/loginUtils";
interface DesktopLogsTableProps {
logs: UserLoginLog[];
@@ -42,13 +43,21 @@ export const DesktopLogsTable = ({
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>
{formatDate(log.lastLoginAt)} {formatTime(log.lastLoginAt)}
{formatDate(log.lastLoginAt)} {formatTimeWithSeconds(log.lastLoginAt)}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>{log.activity}</Text>
<Text
numberOfLines={1}
style={{ color: log.status === "FAILURE" ? "#F83434" : "#22C927" }}
>
{log.activity}
{log.status === "FAILURE" && reasonLabel(log.failureReason)
? ` - ${reasonLabel(log.failureReason)}`
: ""}
</Text>
</View>
</DataTable.Cell>
</DataTable.Row>
+10 -3
View File
@@ -1,7 +1,8 @@
import { ScrollView, Text, View } from "react-native";
import { UserLoginLog } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
import { formatDate, formatTimeWithSeconds } from "@/utils/dateUtils";
import { reasonLabel } from "@/utils/loginUtils";
interface MobileLogsTableProps {
logs: UserLoginLog[];
@@ -21,10 +22,16 @@ export const MobileLogsTable = ({ logs }: MobileLogsTableProps) => {
<Text className="font-normal text-gray-600">({log.email})</Text>
</Text>
<Text className="text-sm text-gray-700 mt-1">
Time: {formatDate(log.lastLoginAt)} {formatTime(log.lastLoginAt)}
Time: {formatDate(log.lastLoginAt)} {formatTimeWithSeconds(log.lastLoginAt)}
</Text>
<Text className="text-sm text-gray-700 mt-1">
<Text
className="text-sm mt-1"
style={{ color: log.status === "FAILURE" ? "#F83434" : "#22C927" }}
>
Login activity: {log.activity}
{log.status === "FAILURE" && reasonLabel(log.failureReason)
? ` - ${reasonLabel(log.failureReason)}`
: ""}
</Text>
</View>
))}
+9 -2
View File
@@ -19,7 +19,7 @@ export const useDocumentTitle = () => {
} else if (path.includes("/settings")) {
return "Settings";
} else {
return "Project Athena";
return "Bird Eye View";
}
};
@@ -27,7 +27,14 @@ export const useDocumentTitle = () => {
useEffect(() => {
if (Platform.OS === "web") {
document.title = `${currentSection} | Project Athena`;
const isAuthRoute =
pathname.includes("/(auth)/") ||
pathname.includes("/sign-in") ||
pathname.includes("/forgot-password") ||
pathname.includes("/reset-password");
document.title = isAuthRoute
? "Bird Eye View"
: `Bird Eye View | ${currentSection}`;
}
}, [currentSection, pathname]);
+1093 -757
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -68,6 +68,7 @@
"eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0",
"json-server": "^1.0.0-beta.3",
"react-native-worklets": "^0.5.2",
"typescript": "~5.8.3"
},
"private": true
+1 -1
View File
@@ -10,7 +10,7 @@ export const getApiUrl = () => {
if (!isDev) return prodUrl;
if (Platform.OS === "web") {
return process.env.EXPO_PUBLIC_BACKEND_URL;
return process.env.EXPO_PUBLIC_BACKEND_URL || "http://localhost:5000/api";
}
try {
+28
View File
@@ -26,6 +26,16 @@ export type UserLoginLog = {
email: string;
lastLoginAt: string | Date;
activity: string;
status?: "SUCCESS" | "FAILURE";
failureReason?:
| "MISSING_CREDENTIALS"
| "INVALID_CREDENTIALS"
| "WRONG_PASSWORD"
| "ACCOUNT_LOCKED"
| "NOT_ACTIVATED"
| "NOT_ALLOWED"
| "UNKNOWN"
| null;
};
const normalizeSortDir = (dir?: TableSortDirection) => {
@@ -82,6 +92,23 @@ export const usersApi = baseApi.injectEndpoints({
providesTags: ["UserLogs"],
}),
getUserLogsById: builder.query<
{ items: UserLoginLog[]; total: number; page: number; limit: number },
{ userId: string; page?: number; limit?: number }
>({
query: ({ userId, page, limit }) => ({
url: `/users/${userId}/logs`,
params: {
...(page !== undefined && { page }),
...(limit !== undefined && { limit }),
},
}),
providesTags: (_res, _err, { userId }) => [
"UserLogs",
{ type: "UserLogs", id: userId } as any,
],
}),
getUsers: builder.query<PaginatedUsers, UsersQueryParams | undefined>({
query: (params) => ({ url: "/users", params: buildUserParams(params) }),
providesTags: (result) => [
@@ -139,6 +166,7 @@ export const usersApi = baseApi.injectEndpoints({
export const {
useGetUserLogsQuery,
useGetUserLogsByIdQuery,
useGetUsersQuery,
useGetUserByIdQuery,
useDeleteUserMutation,
+9 -1
View File
@@ -6,6 +6,14 @@ export const formatTime = (dateInput: string | Date | null | undefined) => {
return format(date, "HH:mm");
};
export const formatTimeWithSeconds = (
dateInput: string | Date | null | undefined
) => {
if (!dateInput) return "";
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
return format(date, "HH:mm:ss");
};
export const formatDate = (dateInput: string | Date | null | undefined) => {
if (!dateInput) return "";
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
@@ -32,7 +40,7 @@ export const formatDate = (dateInput: string | Date | null | undefined) => {
if (isSameWeek(date, now, { weekStartsOn: 1 })) {
return format(date, "EEEE");
}
return format(date, "dd/MM");
return format(date, "MM/dd");
};
export const formatDateMDY = (dateInput: string | Date | null | undefined) => {
+30
View File
@@ -0,0 +1,30 @@
export enum LoginFailureReason {
MISSING_CREDENTIALS = "MISSING_CREDENTIALS",
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
WRONG_PASSWORD = "WRONG_PASSWORD",
ACCOUNT_LOCKED = "ACCOUNT_LOCKED",
NOT_ACTIVATED = "NOT_ACTIVATED",
NOT_ALLOWED = "NOT_ALLOWED",
UNKNOWN = "UNKNOWN",
}
export const reasonLabel = (reason?: LoginFailureReason | string | null) => {
switch (reason) {
case "MISSING_CREDENTIALS":
return "Missing credentials";
case "INVALID_CREDENTIALS":
return "Invalid credentials";
case "WRONG_PASSWORD":
return "Wrong password";
case "ACCOUNT_LOCKED":
return "Account locked";
case "NOT_ACTIVATED":
return "Account not activated";
case "NOT_ALLOWED":
return "Not allowed";
case "UNKNOWN":
return "Unknown";
default:
return null;
}
};