login time
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text, TouchableOpacity } from "react-native";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -8,10 +8,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { AuthLayout } from "@/components/auth/AuthLayout";
|
import { AuthLayout } from "@/components/auth/AuthLayout";
|
||||||
import { AuthHeader } from "@/components/auth/AuthHeader";
|
import { AuthHeader } from "@/components/auth/AuthHeader";
|
||||||
import { AuthCard } from "@/components/auth/AuthCard";
|
import { AuthCard } from "@/components/auth/AuthCard";
|
||||||
import { AuthFooter } from "@/components/auth/AuthFooter";
|
|
||||||
import { FormInput } from "@/components/auth/FormInput";
|
import { FormInput } from "@/components/auth/FormInput";
|
||||||
import { getApiUrl } from "@/store/api/baseApi";
|
import { getApiUrl } from "@/store/api/baseApi";
|
||||||
import { AuthButton } from "@/components/auth/AuthButton";
|
import { AuthButton } from "@/components/auth/AuthButton";
|
||||||
|
import { MessageDisplay } from "@/components/auth/MessageDisplay";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.email("Enter a valid email"),
|
email: z.email("Enter a valid email"),
|
||||||
@@ -72,16 +72,12 @@ const ForgotPasswordScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthHeader title="Project Athena" />
|
<AuthHeader title="Bird Eye View" />
|
||||||
<AuthCard title="Forgot Password" subtitle="We'll email you a reset link">
|
<AuthCard title="Forgot Password" subtitle="We'll email you a reset link">
|
||||||
{!!error && (
|
{error ? <MessageDisplay message={error} type="error" /> : null}
|
||||||
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
|
{message ? <MessageDisplay message={message} type="success" /> : null}
|
||||||
)}
|
|
||||||
{!!message && (
|
|
||||||
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
|
|
||||||
)}
|
|
||||||
<FormInput
|
<FormInput
|
||||||
label={errors.email?.message || "Email"}
|
label="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={(t) => setValue("email", t, { shouldValidate: true })}
|
onChangeText={(t) => setValue("email", t, { shouldValidate: true })}
|
||||||
icon="email"
|
icon="email"
|
||||||
@@ -90,22 +86,31 @@ const ForgotPasswordScreen = () => {
|
|||||||
returnKeyType="send"
|
returnKeyType="send"
|
||||||
submitBehavior="blurAndSubmit"
|
submitBehavior="blurAndSubmit"
|
||||||
/>
|
/>
|
||||||
<View style={{ marginTop: 8 }}>
|
{errors.email?.message ? (
|
||||||
<AuthButton
|
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
|
||||||
onPress={handleSubmit(onSubmit)}
|
{errors.email.message}
|
||||||
loading={submitting}
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<AuthButton
|
||||||
|
onPress={handleSubmit(onSubmit)}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Sending..." : "Send Reset Link"}
|
||||||
|
</AuthButton>
|
||||||
|
|
||||||
|
<View style={{ alignItems: "center", marginTop: 16 }}>
|
||||||
|
<Text>Remembered your password?</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/sign-in")}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{submitting ? "Sending..." : "Send Reset Link"}
|
<Text className="text-primaryPurpleDark font-bold mt-4">
|
||||||
</AuthButton>
|
Sign in
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</AuthCard>
|
</AuthCard>
|
||||||
<AuthFooter
|
|
||||||
question="Remembered your password?"
|
|
||||||
actionText="Sign in"
|
|
||||||
onPress={() => router.push("/sign-in")}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const ResetPasswordScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthHeader title="Project Athena" />
|
<AuthHeader title="Bird Eye View" />
|
||||||
<AuthCard
|
<AuthCard
|
||||||
title="Reset Password"
|
title="Reset Password"
|
||||||
subtitle={
|
subtitle={
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const SignIn = () => {
|
|||||||
useWarmUpBrowser();
|
useWarmUpBrowser();
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const successMessage = "";
|
const successMessage = "";
|
||||||
const isResettingPassword = false;
|
const isResettingPassword = false;
|
||||||
|
|
||||||
@@ -43,15 +42,12 @@ const SignIn = () => {
|
|||||||
password: string;
|
password: string;
|
||||||
}) => {
|
}) => {
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await login(vals.emailAddress, vals.password);
|
await login(vals.emailAddress, vals.password);
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.message || "Invalid credentials";
|
const msg = err?.message || "Invalid credentials";
|
||||||
setErrorMessage(msg);
|
setErrorMessage(msg);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,20 +59,20 @@ const SignIn = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthHeader title="Project Athena" />
|
<AuthHeader title="Bird Eye View" />
|
||||||
|
|
||||||
<AuthCard title="Welcome Back" subtitle="Sign in to continue">
|
<AuthCard title="Welcome Back" subtitle="Sign in to continue">
|
||||||
<SignInForm
|
<SignInForm
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
successMessage={successMessage}
|
successMessage={successMessage}
|
||||||
isLoading={isLoading}
|
isLoading={loading}
|
||||||
onSignInSubmit={onSignInSubmit}
|
onSignInSubmit={onSignInSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ForgotPasswordLink
|
<ForgotPasswordLink
|
||||||
onPress={onForgotPasswordPress}
|
onPress={onForgotPasswordPress}
|
||||||
isResetting={isResettingPassword}
|
isResetting={isResettingPassword}
|
||||||
disabled={isResettingPassword || isLoading}
|
disabled={isResettingPassword || loading}
|
||||||
/>
|
/>
|
||||||
</AuthCard>
|
</AuthCard>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|||||||
+52
-32
@@ -4,6 +4,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
const [tokenState, setTokenState] = useState<string | null>(getToken());
|
const [tokenState, setTokenState] = useState<string | null>(getToken());
|
||||||
const [bootstrapping, setBootstrapping] = useState(true);
|
const [bootstrapping, setBootstrapping] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loginInFlightRef = useRef(false);
|
||||||
|
const loginPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
const isAuthenticated = !!tokenState && !!user;
|
const isAuthenticated = !!tokenState && !!user;
|
||||||
|
|
||||||
@@ -79,39 +82,56 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login: AuthContextType["login"] = async (email, password) => {
|
const login: AuthContextType["login"] = async (email, password) => {
|
||||||
setLoading(true);
|
if (loginInFlightRef.current && loginPromiseRef.current) {
|
||||||
try {
|
return await loginPromiseRef.current;
|
||||||
const res = await fetch(`${getApiUrl()?.replace(/\/$/, "")}/auth/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const status = res.status;
|
|
||||||
let msg = "Invalid credentials";
|
|
||||||
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)) {
|
|
||||||
msg =
|
|
||||||
"Account is not activated. Please use your invitation link to set a password.";
|
|
||||||
}
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
await setToken(data.token);
|
|
||||||
setTokenState(data.token);
|
|
||||||
setUser(data.user);
|
|
||||||
// Clear/refresh caches after login for correct user context
|
|
||||||
store.dispatch(baseApi.util.resetApiState());
|
|
||||||
store.dispatch(baseApi.util.invalidateTags(["Users", "Todos"]));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginInFlightRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const p = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()?.replace(/\/$/, "")}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const status = res.status;
|
||||||
|
let msg = "Invalid credentials";
|
||||||
|
let serverMsg: string | undefined;
|
||||||
|
try {
|
||||||
|
const errData = await res.json();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
await setToken(data.token);
|
||||||
|
setTokenState(data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
// Clear/refresh caches after login for correct user context
|
||||||
|
store.dispatch(baseApi.util.resetApiState());
|
||||||
|
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 ({
|
const register: AuthContextType["register"] = async ({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ interface AuthHeaderProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthHeader = ({ title = "Project Athena" }: AuthHeaderProps) => {
|
export const AuthHeader = ({ title = "Bird Eye View" }: AuthHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ alignItems: "center", marginBottom: 36 }}>
|
<View style={{ alignItems: "center", marginBottom: 36 }}>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const ForgotPasswordLink = ({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled || isResetting}
|
disabled={disabled || isResetting}
|
||||||
>
|
>
|
||||||
<Text className="text-primaryPurple">
|
<Text className="text-primaryPurpleDark font-bold">
|
||||||
{isResetting ? "Sending reset email..." : "Forgot Password?"}
|
{isResetting ? "Sending reset email..." : "Forgot Password?"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
@@ -32,6 +32,20 @@ export const PasswordInput = ({
|
|||||||
submitBehavior,
|
submitBehavior,
|
||||||
}: PasswordInputProps) => {
|
}: PasswordInputProps) => {
|
||||||
const [secureTextEntry, setSecureTextEntry] = useState(true);
|
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 (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -46,12 +60,12 @@ export const PasswordInput = ({
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={onSubmitEditing}
|
onSubmitEditing={() => triggerSubmitOnce()}
|
||||||
returnKeyType={returnKeyType}
|
returnKeyType={returnKeyType}
|
||||||
submitBehavior={submitBehavior ?? "submit"}
|
submitBehavior={submitBehavior ?? "submit"}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (Platform.OS === "web" && e?.nativeEvent?.key === "Enter") {
|
if (Platform.OS === "web" && e?.nativeEvent?.key === "Enter") {
|
||||||
onSubmitEditing?.();
|
triggerSubmitOnce();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
left={<TextInput.Icon icon="lock" color="#6750a4" />}
|
left={<TextInput.Icon icon="lock" color="#6750a4" />}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const SignInForm = ({
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text style={{ color: "#F83434", marginBottom: 8 }}>
|
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
|
||||||
{error.message}
|
{error.message}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -92,11 +92,10 @@ export const SignInForm = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
submitBehavior="blurAndSubmit"
|
|
||||||
onSubmitEditing={() => handleSubmit(submit)()}
|
onSubmitEditing={() => handleSubmit(submit)()}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text style={{ color: "#F83434", marginBottom: 8 }}>
|
<Text style={{ color: "#F83434", marginTop: -4, marginBottom: 8 }}>
|
||||||
{error.message}
|
{error.message}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : 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 { View, Text } from "react-native";
|
||||||
|
|
||||||
import { User } from "@/store/models/User.model";
|
import { User } from "@/store/models/User.model";
|
||||||
|
import { useGetUserLogsByIdQuery } from "@/store/api/usersApi";
|
||||||
|
import { LoginListItem } from "@/components/features/accounts/LoginListItem";
|
||||||
|
|
||||||
interface PersonalLoginListProps {
|
interface PersonalLoginListProps {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PersonalLoginList = ({ user }: PersonalLoginListProps) => {
|
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 (
|
return (
|
||||||
<View className="bg-lightGray rounded-md">
|
<View className="bg-lightGray rounded-md">
|
||||||
<Text className="font-medium my-4 text-gray-600">Log-in List:</Text>
|
<Text className="font-medium mb-4 mt-0 text-gray-600">Log-in List:</Text>
|
||||||
<Text className="font-medium text-black">No logins assigned</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { InfoRow } from "./InfoRow";
|
|||||||
import { useAppSelector } from "@/store/hooks";
|
import { useAppSelector } from "@/store/hooks";
|
||||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||||
import { useGetUserLogsQuery } from "@/store/api/usersApi";
|
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 { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||||
import { SizeEnum } from "@/constants/sizeEnum";
|
import { SizeEnum } from "@/constants/sizeEnum";
|
||||||
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
||||||
@@ -35,7 +35,7 @@ export const PersonalMetrics = ({ user }: PersonalMetricsProps) => {
|
|||||||
: logsError
|
: logsError
|
||||||
? "-"
|
? "-"
|
||||||
: lastLoginAt
|
: lastLoginAt
|
||||||
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
|
? `${formatDate(lastLoginAt)} ${formatTimeWithSeconds(lastLoginAt)}`
|
||||||
: "-";
|
: "-";
|
||||||
|
|
||||||
const lastPasswordChangeDisplay = logsLoading
|
const lastPasswordChangeDisplay = logsLoading
|
||||||
@@ -43,7 +43,7 @@ export const PersonalMetrics = ({ user }: PersonalMetricsProps) => {
|
|||||||
: logsError
|
: logsError
|
||||||
? "-"
|
? "-"
|
||||||
: activity === "Password reset" && lastLoginAt
|
: activity === "Password reset" && lastLoginAt
|
||||||
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
|
? `${formatDate(lastLoginAt)} ${formatTimeWithSeconds(lastLoginAt)}`
|
||||||
: "-";
|
: "-";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const PersonalTodoList = ({ user }: PersonalTodoListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-lightGray rounded-md">
|
<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 ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { DataTable, Text } from "react-native-paper";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
import { UserLoginLog } from "@/store/api/usersApi";
|
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 { LogsTableHeader } from "@/components/features/logs/LogsTableHeader";
|
||||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||||
|
import { reasonLabel } from "@/utils/loginUtils";
|
||||||
|
|
||||||
interface DesktopLogsTableProps {
|
interface DesktopLogsTableProps {
|
||||||
logs: UserLoginLog[];
|
logs: UserLoginLog[];
|
||||||
@@ -42,13 +43,21 @@ export const DesktopLogsTable = ({
|
|||||||
<DataTable.Cell>
|
<DataTable.Cell>
|
||||||
<View className="flex-row items-center flex-shrink pr-2">
|
<View className="flex-row items-center flex-shrink pr-2">
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{formatDate(log.lastLoginAt)} {formatTime(log.lastLoginAt)}
|
{formatDate(log.lastLoginAt)} {formatTimeWithSeconds(log.lastLoginAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</DataTable.Cell>
|
</DataTable.Cell>
|
||||||
<DataTable.Cell>
|
<DataTable.Cell>
|
||||||
<View className="flex-row items-center flex-shrink pr-2">
|
<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>
|
</View>
|
||||||
</DataTable.Cell>
|
</DataTable.Cell>
|
||||||
</DataTable.Row>
|
</DataTable.Row>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ScrollView, Text, View } from "react-native";
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
|
||||||
import { UserLoginLog } from "@/store/api/usersApi";
|
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 {
|
interface MobileLogsTableProps {
|
||||||
logs: UserLoginLog[];
|
logs: UserLoginLog[];
|
||||||
@@ -21,10 +22,16 @@ export const MobileLogsTable = ({ logs }: MobileLogsTableProps) => {
|
|||||||
<Text className="font-normal text-gray-600">({log.email})</Text>
|
<Text className="font-normal text-gray-600">({log.email})</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-gray-700 mt-1">
|
<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>
|
||||||
<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}
|
Login activity: {log.activity}
|
||||||
|
{log.status === "FAILURE" && reasonLabel(log.failureReason)
|
||||||
|
? ` - ${reasonLabel(log.failureReason)}`
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const useDocumentTitle = () => {
|
|||||||
} else if (path.includes("/settings")) {
|
} else if (path.includes("/settings")) {
|
||||||
return "Settings";
|
return "Settings";
|
||||||
} else {
|
} else {
|
||||||
return "Project Athena";
|
return "Bird Eye View";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +27,14 @@ export const useDocumentTitle = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
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]);
|
}, [currentSection, pathname]);
|
||||||
|
|
||||||
|
|||||||
Generated
+1093
-757
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@
|
|||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~9.2.0",
|
"eslint-config-expo": "~9.2.0",
|
||||||
"json-server": "^1.0.0-beta.3",
|
"json-server": "^1.0.0-beta.3",
|
||||||
|
"react-native-worklets": "^0.5.2",
|
||||||
"typescript": "~5.8.3"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const getApiUrl = () => {
|
|||||||
if (!isDev) return prodUrl;
|
if (!isDev) return prodUrl;
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
return process.env.EXPO_PUBLIC_BACKEND_URL;
|
return process.env.EXPO_PUBLIC_BACKEND_URL || "http://localhost:5000/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ export type UserLoginLog = {
|
|||||||
email: string;
|
email: string;
|
||||||
lastLoginAt: string | Date;
|
lastLoginAt: string | Date;
|
||||||
activity: string;
|
activity: string;
|
||||||
|
status?: "SUCCESS" | "FAILURE";
|
||||||
|
failureReason?:
|
||||||
|
| "MISSING_CREDENTIALS"
|
||||||
|
| "INVALID_CREDENTIALS"
|
||||||
|
| "WRONG_PASSWORD"
|
||||||
|
| "ACCOUNT_LOCKED"
|
||||||
|
| "NOT_ACTIVATED"
|
||||||
|
| "NOT_ALLOWED"
|
||||||
|
| "UNKNOWN"
|
||||||
|
| null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSortDir = (dir?: TableSortDirection) => {
|
const normalizeSortDir = (dir?: TableSortDirection) => {
|
||||||
@@ -82,6 +92,23 @@ export const usersApi = baseApi.injectEndpoints({
|
|||||||
providesTags: ["UserLogs"],
|
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>({
|
getUsers: builder.query<PaginatedUsers, UsersQueryParams | undefined>({
|
||||||
query: (params) => ({ url: "/users", params: buildUserParams(params) }),
|
query: (params) => ({ url: "/users", params: buildUserParams(params) }),
|
||||||
providesTags: (result) => [
|
providesTags: (result) => [
|
||||||
@@ -139,6 +166,7 @@ export const usersApi = baseApi.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetUserLogsQuery,
|
useGetUserLogsQuery,
|
||||||
|
useGetUserLogsByIdQuery,
|
||||||
useGetUsersQuery,
|
useGetUsersQuery,
|
||||||
useGetUserByIdQuery,
|
useGetUserByIdQuery,
|
||||||
useDeleteUserMutation,
|
useDeleteUserMutation,
|
||||||
|
|||||||
+9
-1
@@ -6,6 +6,14 @@ export const formatTime = (dateInput: string | Date | null | undefined) => {
|
|||||||
return format(date, "HH:mm");
|
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) => {
|
export const formatDate = (dateInput: string | Date | null | undefined) => {
|
||||||
if (!dateInput) return "";
|
if (!dateInput) return "";
|
||||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
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 })) {
|
if (isSameWeek(date, now, { weekStartsOn: 1 })) {
|
||||||
return format(date, "EEEE");
|
return format(date, "EEEE");
|
||||||
}
|
}
|
||||||
return format(date, "dd/MM");
|
return format(date, "MM/dd");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDateMDY = (dateInput: string | Date | null | undefined) => {
|
export const formatDateMDY = (dateInput: string | Date | null | undefined) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user