use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
macro_rules! id_newtype {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct $name(Uuid);
impl $name {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
pub fn from_uuid(id: Uuid) -> Self {
Self(id)
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().map(Self)
}
}
};
}
id_newtype!(UserId);
id_newtype!(SessionId);
id_newtype!(RoleId);
id_newtype!(PermissionId);
id_newtype!(ResetTokenId);
id_newtype!(AuditEntryId);
id_newtype!(ApiTokenId);
id_newtype!(OAuthAccountId);
id_newtype!(OAuthStateId);
id_newtype!(MfaSecretId);
id_newtype!(MfaRecoveryCodeId);
id_newtype!(MfaChallengeId);
id_newtype!(InvitationId);
id_newtype!(ApplicationId);
id_newtype!(AuthorizationCodeId);
id_newtype!(RefreshTokenId);
id_newtype!(ConsentId);
id_newtype!(SigningKeyId);
id_newtype!(VerificationTokenId);
id_newtype!(SocialProviderId);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct Email(String);
impl Email {
pub fn new(s: String) -> Result<Self, crate::error::AuthError> {
let trimmed = s.trim().to_string();
let parts: Vec<&str> = trimmed.splitn(3, '@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(crate::error::AuthError::InvalidEmail);
}
Ok(Self(trimmed))
}
pub fn as_str(&self) -> &str {
&self.0
}
#[allow(dead_code)]
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct Username(String);
impl Username {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
#[allow(dead_code)]
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, sqlx::Type)]
#[sqlx(transparent)]
pub struct PasswordHash(String);
impl PasswordHash {
pub fn new_unchecked(s: String) -> Self {
Self(s)
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, sqlx::Type)]
#[sqlx(transparent)]
pub struct TokenHash(String);
impl TokenHash {
#[allow(dead_code)]
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionToken(String);
impl SessionToken {
pub fn from_encoded(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct ClientId(String);
impl ClientId {
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
impl std::fmt::Display for ClientId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum ClientType {
Confidential,
Public,
}
#[derive(Debug, Clone)]
pub struct ClientSecret(String);
impl ClientSecret {
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct RoleName(String);
impl RoleName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
#[allow(dead_code)]
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct PermissionName(String);
impl PermissionName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
#[allow(dead_code)]
pub(crate) fn new_unchecked(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct User {
pub id: UserId,
pub email: Email,
pub username: Option<Username>,
#[serde(skip_serializing)]
pub password_hash: Option<PasswordHash>,
pub email_verified: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub custom_data: Option<Value>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Session {
pub id: SessionId,
pub token_hash: TokenHash,
pub user_id: UserId,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Role {
pub id: RoleId,
pub name: RoleName,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct UserRole {
pub user_id: UserId,
pub role_id: RoleId,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Permission {
pub id: PermissionId,
pub name: PermissionName,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct RolePermission {
pub role_id: RoleId,
pub permission_id: PermissionId,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct UserPermission {
pub user_id: UserId,
pub permission_id: PermissionId,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ApiTokenInfo {
pub id: ApiTokenId,
pub user_id: UserId,
pub name: String,
pub metadata: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
pub enum AccentInk {
Black,
White,
}
impl AccentInk {
pub fn as_str(&self) -> &'static str {
match self {
Self::Black => "black",
Self::White => "white",
}
}
pub fn as_hex(&self) -> &'static str {
match self {
Self::Black => "#000000",
Self::White => "#ffffff",
}
}
}
impl std::str::FromStr for AccentInk {
type Err = crate::error::AuthError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"black" => Ok(Self::Black),
"white" => Ok(Self::White),
_ => Err(crate::error::AuthError::Validation(
"accent_ink must be 'black' or 'white'".into(),
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
pub enum Mode {
Dark,
Light,
}
impl Mode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Dark => "dark",
Self::Light => "light",
}
}
}
impl std::str::FromStr for Mode {
type Err = crate::error::AuthError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"dark" => Ok(Self::Dark),
"light" => Ok(Self::Light),
_ => Err(crate::error::AuthError::Validation(
"forced_mode must be 'dark' or 'light'".into(),
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
pub enum SplashPrimitive {
Wordmark,
Circle,
Grid,
Wave,
}
impl SplashPrimitive {
pub fn as_str(&self) -> &'static str {
match self {
Self::Wordmark => "wordmark",
Self::Circle => "circle",
Self::Grid => "grid",
Self::Wave => "wave",
}
}
}
impl std::str::FromStr for SplashPrimitive {
type Err = crate::error::AuthError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"wordmark" => Ok(Self::Wordmark),
"circle" => Ok(Self::Circle),
"grid" => Ok(Self::Grid),
"wave" => Ok(Self::Wave),
_ => Err(crate::error::AuthError::Validation(
"splash_primitive must be one of wordmark|circle|grid|wave".into(),
)),
}
}
}
#[cfg(test)]
mod wavefunk_enum_tests {
use super::*;
#[test]
fn accent_ink_round_trip() {
assert_eq!(AccentInk::Black.as_str(), "black");
assert_eq!(AccentInk::White.as_str(), "white");
assert_eq!("black".parse::<AccentInk>().unwrap(), AccentInk::Black);
assert_eq!("white".parse::<AccentInk>().unwrap(), AccentInk::White);
assert!("gray".parse::<AccentInk>().is_err());
}
#[test]
fn mode_round_trip() {
assert_eq!(Mode::Dark.as_str(), "dark");
assert_eq!(Mode::Light.as_str(), "light");
assert_eq!("dark".parse::<Mode>().unwrap(), Mode::Dark);
assert_eq!("light".parse::<Mode>().unwrap(), Mode::Light);
assert!("auto".parse::<Mode>().is_err());
}
#[test]
fn splash_primitive_round_trip() {
for (s, v) in [
("wordmark", SplashPrimitive::Wordmark),
("circle", SplashPrimitive::Circle),
("grid", SplashPrimitive::Grid),
("wave", SplashPrimitive::Wave),
] {
assert_eq!(v.as_str(), s);
assert_eq!(s.parse::<SplashPrimitive>().unwrap(), v);
}
assert!("square".parse::<SplashPrimitive>().is_err());
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::UserId;
#[test]
fn userid_fromstr_parses_valid_uuid() {
let s = "550e8400-e29b-41d4-a716-446655440000";
let id = UserId::from_str(s).unwrap();
assert_eq!(id.to_string(), s);
}
#[test]
fn userid_fromstr_rejects_invalid() {
assert!(UserId::from_str("not-a-uuid").is_err());
}
}