merge fixes

This commit is contained in:
Stephan D
2025-11-23 15:45:10 +01:00
228 changed files with 5100 additions and 5433 deletions

View File

@@ -1,2 +1,22 @@
class AuthorizationFailed implements Exception {
class AuthenticationFailedException implements Exception {
final String message;
final Exception? originalError;
const AuthenticationFailedException(this.message, [this.originalError]);
@override
String toString() {
return 'AuthenticationFailedException: $message${originalError != null ? ' (caused by: $originalError)' : ''}';
}
}
class CircuitBreakerOpenException implements Exception {
final String message;
const CircuitBreakerOpenException(this.message);
@override
String toString() {
return 'CircuitBreakerOpenException: $message';
}
}

View File

@@ -1,20 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/login_data.dart';
part 'login.g.dart';
@JsonSerializable(explicitToJson: true)
class LoginRequest {
final String login;
final String password;
final String locale;
final LoginData login;
final String clientId;
final String deviceId;
const LoginRequest({
required this.login,
required this.password,
required this.locale,
required this.clientId,
required this.deviceId,
});

View File

@@ -0,0 +1,76 @@
import 'package:json_annotation/json_annotation.dart';
part 'login_data.g.dart';
@JsonSerializable(explicitToJson: true, constructor: 'build')
class LoginData {
final String login;
final String password;
final String locale;
const LoginData._({
required this.login,
required this.password,
required this.locale,
});
factory LoginData.build({
required String login,
required String password,
required String locale,
}) => LoginData._(
login: login.trim().toLowerCase(),
password: password,
locale: locale,
);
factory LoginData.fromJson(Map<String, dynamic> json) => _$LoginDataFromJson(json);
Map<String, dynamic> toJson() => _$LoginDataToJson(this);
}
@JsonSerializable(explicitToJson: true, constructor: 'buildIstance')
class AccountData extends LoginData {
final String name;
final String lastName;
const AccountData._({
required super.login,
required super.password,
required super.locale,
required this.name,
required this.lastName,
}) : super._();
factory AccountData.buildIstance({
required String login,
required String password,
required String locale,
required String name,
required String lastName,
}) => AccountData._(
login: login,
password: password,
locale: locale,
name: name.trim(),
lastName: lastName.trim(),
);
factory AccountData.build({
required LoginData login,
required String name,
required String lastName,
}) => AccountData.buildIstance(
login: login.login,
password: login.password,
locale: login.locale,
name: name,
lastName: lastName,
);
factory AccountData.fromJson(Map<String, dynamic> json) => _$AccountDataFromJson(json);
@override
Map<String, dynamic> toJson() => _$AccountDataToJson(this);
}

View File

@@ -1,6 +1,6 @@
import 'package:json_annotation/json_annotation.dart';
part 'change_password.g.dart';
part 'change.g.dart';
@JsonSerializable(explicitToJson: true)

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'forgot.g.dart';
@JsonSerializable()
class ForgotPasswordRequest {
final String login;
const ForgotPasswordRequest({
required this.login,
});
factory ForgotPasswordRequest.fromJson(Map<String, dynamic> json) => _$ForgotPasswordRequestFromJson(json);
Map<String, dynamic> toJson() => _$ForgotPasswordRequestToJson(this);
static ForgotPasswordRequest build({
required String login,
}) => ForgotPasswordRequest(login: login);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'reset.g.dart';
@JsonSerializable()
class ResetPasswordRequest {
final String password;
const ResetPasswordRequest({
required this.password,
});
factory ResetPasswordRequest.fromJson(Map<String, dynamic> json) => _$ResetPasswordRequestFromJson(json);
Map<String, dynamic> toJson() => _$ResetPasswordRequestToJson(this);
static ResetPasswordRequest build({
required String password,
}) => ResetPasswordRequest(password: password);
}

View File

@@ -1,40 +1,37 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/models/describable.dart';
part 'signup.g.dart';
@JsonSerializable(explicitToJson: true)
class SignupRequest {
final String name;
final String login;
final String password;
final String locale;
final String organizationName;
final AccountData account;
final DescribableDTO organization;
final String organizationTimeZone;
final DescribableDTO ownerRole;
const SignupRequest({
required this.name,
required this.login,
required this.password,
required this.locale,
required this.organizationName,
required this.account,
required this.organization,
required this.organizationTimeZone,
required this.ownerRole,
});
factory SignupRequest.build({
required String name,
required String login,
required String password,
required String locale,
required String organizationName,
required AccountData account,
required Describable organization,
required String organizationTimeZone,
required Describable ownerRole,
}) => SignupRequest(
name: name,
login: login,
password: password,
locale: locale,
organizationName: organizationName,
account: account,
organization: organization.toDTO(),
organizationTimeZone: organizationTimeZone,
ownerRole: ownerRole.toDTO(),
);
factory SignupRequest.fromJson(Map<String, dynamic> json) => _$SignupRequestFromJson(json);

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/token.dart';
part 'login_pending.g.dart';
@JsonSerializable(explicitToJson: true)
class PendingLoginResponse {
final AccountResponse account;
final TokenData pendingToken;
final String destination;
final int ttlSeconds;
const PendingLoginResponse({
required this.account,
required this.pendingToken,
required this.destination,
required this.ttlSeconds,
});
factory PendingLoginResponse.fromJson(Map<String, dynamic> json) => _$PendingLoginResponseFromJson(json);
Map<String, dynamic> toJson() => _$PendingLoginResponseToJson(this);
}

View File

@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
class CommonConstants {
static String apiProto = const String.fromEnvironment('API_PROTO', defaultValue: 'http');
static String apiHost = const String.fromEnvironment('API_HOST', defaultValue: 'localhost');
static String apiProto = 'https';
static String apiHost = 'app.sendico.io';
// static String apiProto = const String.fromEnvironment('API_PROTO', defaultValue: 'http');
// static String apiHost = const String.fromEnvironment('API_HOST', defaultValue: 'localhost');
// static String apiHost = 'localhost';
// static String apiHost = '10.0.2.2';
static String apiEndpoint = '/api/v1';

View File

@@ -1,6 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/account/base.dart';
import 'package:pshared/data/dto/date_time.dart';
part 'account.g.dart';
@@ -8,14 +9,17 @@ part 'account.g.dart';
@JsonSerializable()
class AccountDTO extends AccountBaseDTO {
final String login;
final String locale;
const AccountDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required super.name,
required super.lastName,
required super.description,
required super.avatarUrl,
required super.locale,
required this.locale,
required this.login,
});

View File

@@ -1,6 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/storable.dart';
import 'package:pshared/data/dto/date_time.dart';
part 'base.g.dart';
@@ -8,7 +9,8 @@ part 'base.g.dart';
@JsonSerializable()
class AccountBaseDTO extends StorableDTO {
final String name;
final String locale;
final String lastName;
final String? description;
final String? avatarUrl;
const AccountBaseDTO({
@@ -16,8 +18,9 @@ class AccountBaseDTO extends StorableDTO {
required super.createdAt,
required super.updatedAt,
required this.name,
required this.description,
required this.avatarUrl,
required this.locale,
required this.lastName,
});
factory AccountBaseDTO.fromJson(Map<String, dynamic> json) => _$AccountBaseDTOFromJson(json);

View File

@@ -0,0 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
class UtcIso8601Converter implements JsonConverter<DateTime, String> {
const UtcIso8601Converter();
@override
DateTime fromJson(String json) => DateTime.parse(json).toUtc();
@override
String toJson(DateTime value) => value.toUtc().toIso8601String();
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'describable.g.dart';
@JsonSerializable()
class DescribableDTO {
final String name;
final String? description;
const DescribableDTO({
required this.name,
this.description,
});
factory DescribableDTO.fromJson(Map<String, dynamic> json) => _$DescribableDTOFromJson(json);
Map<String, dynamic> toJson() => _$DescribableDTOToJson(this);
}

View File

@@ -1,18 +1,28 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/storable.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/data/dto/permissions/bound.dart';
part 'organization.g.dart';
@JsonSerializable()
class OrganizationDTO extends StorableDTO {
class OrganizationDTO extends PermissionBoundDTO {
final String name;
final String? description;
final String timeZone;
final String? logoUrl;
final String tenantRef;
const OrganizationDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required super.permissionRef,
required super.organizationRef,
required this.name,
required this.tenantRef,
this.description,
required this.timeZone,
this.logoUrl,
});

View File

@@ -0,0 +1,23 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/storable.dart';
import 'package:pshared/data/dto/date_time.dart';
part 'bound.g.dart';
@JsonSerializable()
class OrganizationBoundDTO extends StorableDTO {
final String organizationRef;
const OrganizationBoundDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required this.organizationRef,
});
factory OrganizationBoundDTO.fromJson(Map<String, dynamic> json) => _$OrganizationBoundDTOFromJson(json);
@override
Map<String, dynamic> toJson() => _$OrganizationBoundDTOToJson(this);
}

View File

@@ -1,13 +1,17 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/describable.dart';
part 'description.g.dart';
@JsonSerializable()
class OrganizationDescriptionDTO {
final DescribableDTO description;
final String? logoUrl;
const OrganizationDescriptionDTO({
required this.description,
this.logoUrl,
});

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/data/dto/storable.dart';
part 'bound.g.dart';
@JsonSerializable()
class PermissionBoundDTO extends StorableDTO {
final String permissionRef;
final String organizationRef;
const PermissionBoundDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required this.permissionRef,
required this.organizationRef,
});
factory PermissionBoundDTO.fromJson(Map<String, dynamic> json) => _$PermissionBoundDTOFromJson(json);
@override
Map<String, dynamic> toJson() => _$PermissionBoundDTOToJson(this);
}

View File

@@ -1,12 +1,14 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/storable.dart';
import 'package:pshared/data/dto/storable/describable.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/models/resources.dart';
part 'policy.g.dart';
@JsonSerializable()
class PolicyDescriptionDTO extends StorableDTO {
class PolicyDescriptionDTO extends StorableDescribabaleDTO {
final List<ResourceType>? resourceTypes;
final String? organizationRef;
@@ -14,6 +16,8 @@ class PolicyDescriptionDTO extends StorableDTO {
required super.id,
required super.createdAt,
required super.updatedAt,
required super.name,
required super.description,
required this.resourceTypes,
required this.organizationRef,
});

View File

@@ -1,17 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/storable.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/data/dto/storable/describable.dart';
part 'role.g.dart';
@JsonSerializable()
class RoleDescriptionDTO extends StorableDTO {
class RoleDescriptionDTO extends StorableDescribabaleDTO {
final String organizationRef;
const RoleDescriptionDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required super.name,
required super.description,
required this.organizationRef,
});

View File

@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
part 'reference.g.dart';
@JsonSerializable()
class ReferenceDTO {
final String ref;
const ReferenceDTO({
required this.ref,
});
factory ReferenceDTO.fromJson(Map<String, dynamic> json) => _$ReferenceDTOFromJson(json);
Map<String, dynamic> toJson() => _$ReferenceDTOToJson(this);
}

View File

@@ -1,12 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/date_time.dart';
part 'storable.g.dart';
@JsonSerializable()
class StorableDTO {
final String id;
@UtcIso8601Converter()
final DateTime createdAt;
@UtcIso8601Converter()
final DateTime updatedAt;
const StorableDTO({

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/data/dto/storable.dart';
part 'describable.g.dart';
@JsonSerializable()
class StorableDescribabaleDTO extends StorableDTO {
final String name;
final String? description;
const StorableDescribabaleDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required this.name,
this.description,
});
factory StorableDescribabaleDTO.fromJson(Map<String, dynamic> json) => _$StorableDescribabaleDTOFromJson(json);
@override
Map<String, dynamic> toJson() => _$StorableDescribabaleDTOToJson(this);
}

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import 'package:pshared/data/dto/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
@@ -9,6 +10,8 @@ extension AccountMapper on Account {
createdAt: createdAt,
updatedAt: updatedAt,
name: name,
lastName: lastName,
description: description,
avatarUrl: avatarUrl,
locale: locale,
login: login,
@@ -19,8 +22,9 @@ extension AccountDTOMapper on AccountDTO {
Account toDomain() => Account(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
avatarUrl: avatarUrl,
describable: newDescribable(name: name, description: description),
lastName: lastName,
locale: locale,
login: login,
name: name,
);
}

View File

@@ -1,5 +1,6 @@
import 'package:pshared/data/dto/account/base.dart';
import 'package:pshared/models/account/base.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
@@ -8,17 +9,18 @@ extension AccountBaseMapper on AccountBase {
id: storable.id,
createdAt: storable.createdAt,
updatedAt: storable.updatedAt,
name: describable.name,
description: describable.description,
lastName: lastName,
avatarUrl: avatarUrl,
name: name,
locale: locale,
);
}
extension AccountDTOMapper on AccountBaseDTO {
AccountBase toDomain() => AccountBase(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
describable: newDescribable(name: name, description: description),
lastName: lastName,
avatarUrl: avatarUrl,
name: name,
locale: locale,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/data/dto/describable.dart';
import 'package:pshared/models/describable.dart';
extension DescribableMapper on Describable {
DescribableDTO toDTO() => DescribableDTO(
name: name,
description: description,
);
}
extension DescribableDTOMapper on DescribableDTO {
Describable toDomain() => newDescribable(
name: name,
description: description,
);
}

View File

@@ -1,5 +1,8 @@
import 'package:pshared/data/dto/organization.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/organization/organization.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/storable.dart';
@@ -8,15 +11,26 @@ extension OrganizationMapper on Organization {
id: storable.id,
createdAt: storable.createdAt,
updatedAt: storable.updatedAt,
name: describable.name,
description: describable.description,
timeZone: timeZone,
logoUrl: logoUrl,
organizationRef: permissionBound.organizationRef,
permissionRef: permissionBound.permissionRef,
tenantRef: tenantRef,
);
}
extension OrganizationDTOMapper on OrganizationDTO {
Organization toDomain() => Organization(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
describable: newDescribable(name: name, description: description),
timeZone: timeZone,
logoUrl: logoUrl,
permissionBound: newPermissionBound(
organizationBound: newOrganizationBound(organizationRef: organizationRef),
permissionRef: permissionRef,
),
tenantRef: tenantRef,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/organization/bound.dart';
import 'package:pshared/models/organization/bound.dart';
extension OrganizationBoundMapper on OrganizationBound {
OrganizationBoundDTO toDTO() => OrganizationBoundDTO(
id: '', // OrganizationBound doesn't have storable fields, so we need to provide defaults
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
organizationRef: organizationRef,
);
}
extension OrganizationBoundDTOMapper on OrganizationBoundDTO {
OrganizationBound toDomain() => newOrganizationBound(
organizationRef: organizationRef,
);
}

View File

@@ -1,9 +1,11 @@
import 'package:pshared/data/dto/organization/description.dart';
import 'package:pshared/data/mapper/describable.dart';
import 'package:pshared/models/organization/description.dart';
extension OrganizationDescriptionMapper on OrganizationDescription {
OrganizationDescriptionDTO toDTO() => OrganizationDescriptionDTO(
description: description.toDTO(),
logoUrl: logoUrl,
);
}
@@ -11,5 +13,6 @@ extension OrganizationDescriptionMapper on OrganizationDescription {
extension AccountDescriptionDTOMapper on OrganizationDescriptionDTO {
OrganizationDescription toDomain() => OrganizationDescription(
logoUrl: logoUrl,
description: description.toDomain(),
);
}

View File

@@ -1,4 +1,5 @@
import 'package:pshared/data/dto/permissions/description/policy.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/permissions/descriptions/policy.dart';
import 'package:pshared/models/storable.dart';
@@ -8,6 +9,8 @@ extension PolicyDescriptionMapper on PolicyDescription {
id: storable.id,
createdAt: storable.createdAt,
updatedAt: storable.updatedAt,
name: describable.name,
description: describable.description,
resourceTypes: resourceTypes,
organizationRef: organizationRef,
);
@@ -16,6 +19,7 @@ extension PolicyDescriptionMapper on PolicyDescription {
extension PolicyDescriptionDTOMapper on PolicyDescriptionDTO {
PolicyDescription toDomain() => PolicyDescription(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: createdAt),
describable: newDescribable(name: name, description: description),
resourceTypes: resourceTypes,
organizationRef: organizationRef,
);

View File

@@ -1,4 +1,5 @@
import 'package:pshared/data/dto/permissions/description/role.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pshared/models/storable.dart';
@@ -8,6 +9,8 @@ extension RoleDescriptionMapper on RoleDescription {
id: storable.id,
createdAt: storable.createdAt,
updatedAt: storable.updatedAt,
name: describable.name,
description: describable.description,
organizationRef: organizationRef,
);
}
@@ -15,6 +18,7 @@ extension RoleDescriptionMapper on RoleDescription {
extension RoleDescriptionDTOMapper on RoleDescriptionDTO {
RoleDescription toDomain() => RoleDescription(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
describable: newDescribable(name: name, description: description),
organizationRef: organizationRef,
);
}

View File

@@ -0,0 +1,11 @@
import 'package:pshared/data/dto/reference.dart';
import 'package:pshared/models/reference.dart';
extension ReferenceMapper on Reference {
ReferenceDTO toDTO() => ReferenceDTO(ref: ref);
}
extension ReferenceDTOMapper on ReferenceDTO {
Reference toDomain() => newReference(ref: ref);
}

View File

@@ -3,7 +3,7 @@ import 'package:pshared/models/storable.dart';
extension StorableMapper on Storable {
StorableDTO toDTO() => StorableDTO(id: id, createdAt: createdAt, updatedAt: updatedAt);
StorableDTO toDTO() => StorableDTO(id: id, createdAt: createdAt.toUtc(), updatedAt: updatedAt.toUtc());
}
extension StorableDTOMapper on StorableDTO {

View File

@@ -1,36 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/account/base.dart';
import 'package:pshared/models/describable.dart';
@immutable
class Account extends AccountBase {
final String login;
final String locale;
const Account({
required super.storable,
required super.describable,
required super.avatarUrl,
required super.lastName,
required this.login,
required super.locale,
required super.name,
required this.locale,
});
factory Account.fromBase(AccountBase accountBase, String login) => Account(
storable: accountBase.storable,
avatarUrl: accountBase.avatarUrl,
locale: accountBase.locale,
name: accountBase.name,
login: login,
);
@override
Account copyWith({
Describable? describable,
String? lastName,
String? Function()? avatarUrl,
String? name,
String? locale,
}) {
final updatedBase = super.copyWith(
avatarUrl: avatarUrl,
name: name,
locale: locale,
);
return Account.fromBase(updatedBase, login);
}
}) => Account(
storable: storable,
describable: describableCopyWithOther(this.describable, describable),
lastName: lastName ?? this.lastName,
avatarUrl: avatarUrl != null ? avatarUrl() : this.avatarUrl,
login: login,
locale: locale ?? this.locale,
);
}

View File

@@ -1,8 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/models/storable/describable.dart';
import 'package:pshared/utils/name_initials.dart';
class AccountBase implements Storable {
@immutable
class AccountBase implements StorableDescribable {
final Storable storable;
final Describable describable;
final String lastName;
@override
String get id => storable.id;
@@ -10,26 +18,30 @@ class AccountBase implements Storable {
DateTime get createdAt => storable.createdAt;
@override
DateTime get updatedAt => storable.updatedAt;
@override
String get name => describable.name;
@override
String? get description => describable.description;
final String? avatarUrl;
final String name;
final String locale;
const AccountBase({
required this.storable,
required this.name,
required this.locale,
required this.describable,
required this.avatarUrl,
required this.lastName,
});
String get nameInitials => getNameInitials(describable.name);
AccountBase copyWith({
Describable? describable,
String? lastName,
String? Function()? avatarUrl,
String? name,
String? locale,
}) => AccountBase(
storable: storable,
avatarUrl: avatarUrl != null ? avatarUrl() : this.avatarUrl,
locale: locale ?? this.locale,
name: name ?? this.name,
describable: describable ?? this.describable,
lastName: lastName ?? this.lastName,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/pending_login.dart';
class LoginOutcome {
final Account? account;
final PendingLogin? pending;
const LoginOutcome._({this.account, this.pending});
factory LoginOutcome.completed(Account account) => LoginOutcome._(account: account);
factory LoginOutcome.pending(PendingLogin pending) => LoginOutcome._(pending: pending);
bool get isPending => pending != null;
bool get isCompleted => account != null;
}

View File

@@ -0,0 +1,33 @@
import 'package:pshared/api/responses/login_pending.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/session_identifier.dart';
class PendingLogin {
final Account account;
final TokenData pendingToken;
final String destination;
final int ttlSeconds;
final SessionIdentifier session;
const PendingLogin({
required this.account,
required this.pendingToken,
required this.destination,
required this.ttlSeconds,
required this.session,
});
factory PendingLogin.fromResponse(
PendingLoginResponse response, {
required SessionIdentifier session,
}) => PendingLogin(
account: response.account.account.toDomain(),
pendingToken: response.pendingToken,
destination: response.destination,
ttlSeconds: response.ttlSeconds,
session: session,
);
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
abstract class Describable {
String get name;
String? get description;
}
@immutable
class _DescribableImp implements Describable {
@override
final String name;
@override
final String? description;
const _DescribableImp({
required this.name,
required this.description,
});
}
Describable newDescribable({required String name, String? description}) =>
_DescribableImp(name: name, description: description);
extension DescribableCopier on Describable {
Describable copyWith({
String? name,
String? Function()? description,
}) => newDescribable(
name: name ?? this.name,
description: description != null ? description() : this.description,
);
}
Describable describableCopyWithOther(Describable current, Describable? other) => current.copyWith(
name: other?.name,
description: () => other?.description,
);

View File

@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
abstract class OrganizationBound {
String get organizationRef;
}
@immutable
class _OrganizationBoundImp implements OrganizationBound {
@override
final String organizationRef;
const _OrganizationBoundImp({
required this.organizationRef,
});
}
OrganizationBound newOrganizationBound({ required String organizationRef }) => _OrganizationBoundImp(organizationRef: organizationRef);

View File

@@ -1,7 +1,12 @@
import 'package:pshared/models/describable.dart';
class OrganizationDescription {
final Describable description;
final String? logoUrl;
const OrganizationDescription({
required this.description,
this.logoUrl,
});
}

View File

@@ -1,8 +1,13 @@
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/permissions/bound/describable.dart';
import 'package:pshared/models/storable.dart';
class Organization implements Storable {
class Organization implements PermissionBoundStorableDescribable {
final Storable storable;
final PermissionBound permissionBound;
final Describable describable;
@override
String get id => storable.id;
@@ -10,25 +15,39 @@ class Organization implements Storable {
DateTime get createdAt => storable.createdAt;
@override
DateTime get updatedAt => storable.updatedAt;
@override
String get organizationRef => permissionBound.organizationRef;
@override
String get permissionRef => permissionBound.permissionRef;
@override
String get name => describable.name;
@override
String? get description => describable.description;
final String timeZone;
final String? logoUrl;
final String tenantRef;
const Organization({
required this.storable,
required this.describable,
required this.timeZone,
required this.permissionBound,
required this.tenantRef,
this.logoUrl,
});
Organization copyWith({
String? name,
String? Function()? description,
Describable? describable,
String? timeZone,
String? Function()? logoUrl,
}) => Organization(
storable: storable, // Same Storable, same id
describable: describableCopyWithOther(this.describable, describable),
timeZone: timeZone ?? this.timeZone,
logoUrl: logoUrl != null ? logoUrl() : this.logoUrl,
permissionBound: permissionBound,
tenantRef: tenantRef,
);
}

View File

@@ -1,22 +0,0 @@
import 'package:pshared/config/constants.dart';
abstract class PermissionBound {
String get permissionRef;
String get organizationRef;
}
class _PermissionBoundImp implements PermissionBound {
@override
final String permissionRef;
@override
final String organizationRef;
const _PermissionBoundImp({
required this.permissionRef,
required this.organizationRef,
});
}
PermissionBound newPermissionBound({ required String organizationRef, String? permissionRef}) =>
_PermissionBoundImp(permissionRef: permissionRef ?? Constants.nilObjectRef, organizationRef: organizationRef);

View File

@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/organization/bound.dart';
abstract class PermissionBound extends OrganizationBound {
String get permissionRef;
}
@immutable
class _PermissionBoundImp implements PermissionBound {
@override
final String permissionRef;
final OrganizationBound organizationBound;
@override
get organizationRef => organizationBound.organizationRef;
const _PermissionBoundImp({
required this.permissionRef,
required this.organizationBound,
});
}
PermissionBound newPermissionBound({
required OrganizationBound organizationBound,
String? permissionRef,
}) => _PermissionBoundImp(
permissionRef: permissionRef ?? Constants.nilObjectRef,
organizationBound: organizationBound,
);

View File

@@ -0,0 +1,6 @@
import 'package:pshared/models/permissions/bound/storable.dart';
import 'package:pshared/models/storable/describable.dart';
abstract class PermissionBoundStorableDescribable implements PermissionBoundStorable, StorableDescribable {
}

View File

@@ -1,4 +1,4 @@
import 'package:pshared/models/permission_bound.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/storable.dart';

View File

@@ -1,9 +1,12 @@
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/models/storable/describable.dart';
class PolicyDescription implements Storable {
class PolicyDescription implements StorableDescribable {
final Storable storable;
final Describable describable;
final List<ResourceType>? resourceTypes;
final String? organizationRef;
@@ -13,9 +16,14 @@ class PolicyDescription implements Storable {
DateTime get createdAt => storable.createdAt;
@override
DateTime get updatedAt => storable.updatedAt;
@override
String get name => describable.name;
@override
String? get description => describable.description;
const PolicyDescription({
required this.storable,
required this.describable,
required this.resourceTypes,
required this.organizationRef,
});

View File

@@ -1,8 +1,11 @@
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/models/storable/describable.dart';
class RoleDescription implements Storable {
class RoleDescription implements StorableDescribable {
final Storable storable;
final Describable describable;
@override
String get id => storable.id;
@@ -10,18 +13,25 @@ class RoleDescription implements Storable {
DateTime get createdAt => storable.createdAt;
@override
DateTime get updatedAt => storable.updatedAt;
@override
String get name => describable.name;
@override
String? get description => describable.description;
final String organizationRef;
const RoleDescription({
required this.storable,
required this.describable,
required this.organizationRef,
});
factory RoleDescription.build({
required Describable roleDescription,
required String organizationRef,
}) => RoleDescription(
storable: newStorable(),
describable: roleDescription,
organizationRef: organizationRef
);
}

View File

@@ -0,0 +1,22 @@
abstract class Reference {
String get ref;
}
class _ReferenceImp implements Reference {
@override
final String ref;
const _ReferenceImp({
required this.ref,
});
}
Reference newReference({required String ref}) => _ReferenceImp(ref: ref);
extension ReferenceCopier on Reference {
Reference copyWith({
String? ref,
}) => newReference(
ref: ref ?? this.ref,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'session_identifier.g.dart';
@JsonSerializable()
class SessionIdentifier {
final String clientId;
final String deviceId;
const SessionIdentifier({
required this.clientId,
required this.deviceId,
});
factory SessionIdentifier.fromJson(Map<String, dynamic> json) => _$SessionIdentifierFromJson(json);
Map<String, dynamic> toJson() => _$SessionIdentifierToJson(this);
}

View File

@@ -1,4 +1,6 @@
import 'package:pshared/config/constants.dart';
import 'package:flutter/foundation.dart';
import 'package:pshared/config/web.dart';
abstract class Storable {
@@ -7,6 +9,7 @@ abstract class Storable {
DateTime get updatedAt;
}
@immutable
class _StorableImp implements Storable {
@override
final String id;

View File

@@ -0,0 +1,6 @@
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
abstract class StorableDescribable implements Storable, Describable {
}

View File

@@ -4,21 +4,52 @@ import 'package:share_plus/share_plus.dart';
import 'package:pshared/api/errors/unauthorized.dart';
import 'package:pshared/api/requests/signup.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/provider/locale.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/account.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/utils/exception.dart';
class AccountProvider extends ChangeNotifier {
static String get currentUserRef => Constants.nilObjectRef;
// The resource now wraps our Account? state along with its loading/error state.
Resource<Account?> _resource = Resource(data: null);
Resource<Account?> get resource => _resource;
late LocaleProvider _localeProvider;
PendingLogin? _pendingLogin;
Account? get account => _resource.data;
PendingLogin? get pendingLogin => _pendingLogin;
bool get isLoggedIn => account != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
bool get isReady => (!isLoading) && (account != null);
Account? currentUser() {
final acc = account;
if (acc == null) return null;
return Account(
storable: newStorable(
id: currentUserRef,
createdAt: acc.createdAt,
updatedAt: acc.updatedAt,
),
describable: acc.describable,
lastName: acc.lastName,
avatarUrl: acc.avatarUrl,
login: acc.login,
locale: acc.locale,
);
}
// Private helper to update the resource and notify listeners.
void _setResource(Resource<Account?> newResource) {
@@ -26,28 +57,50 @@ class AccountProvider extends ChangeNotifier {
notifyListeners();
}
void updateProvider(LocaleProvider localeProvider) => _localeProvider = localeProvider;
Future<Account?> login({
void _pickupLocale(String locale) => _localeProvider.setLocale(Locale(locale));
Future<LoginOutcome> login({
required String email,
required String password,
required String locale,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await AccountService.login(email, password, locale);
_setResource(Resource(data: acc, isLoading: false));
return acc;
final outcome = await AccountService.login(LoginData.build(
login: email,
password: password,
locale: locale,
));
if (outcome.account != null) {
_setResource(Resource(data: outcome.account, isLoading: false));
_pickupLocale(outcome.account!.locale);
} else {
_pendingLogin = outcome.pending;
_setResource(_resource.copyWith(isLoading: false));
}
return outcome;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
void completePendingLogin(Account account) {
_pendingLogin = null;
_setResource(Resource(data: account, isLoading: false, error: null));
_pickupLocale(account.locale);
}
Future<bool> isAuthorizationStored() async => AuthorizationService.isAuthorizationStored();
Future<Account?> restore() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await AccountService.restore();
_setResource(Resource(data: acc, isLoading: false));
_pickupLocale(acc.locale);
return acc;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
@@ -55,24 +108,20 @@ class AccountProvider extends ChangeNotifier {
}
}
Future<void> signup(
String name,
String login,
String password,
String locale,
String organizationName,
String timezone,
) async {
Future<void> signup({
required AccountData account,
required Describable organization,
required String timezone,
required Describable ownerRole,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.signup(
SignupRequest.build(
name: name,
login: login.trim().toLowerCase(),
password: password,
locale: locale,
organizationName: organizationName,
account: account,
organization: organization,
organizationTimeZone: timezone,
ownerRole: ownerRole,
),
);
// Signup might not automatically log in the user,
@@ -96,6 +145,7 @@ class AccountProvider extends ChangeNotifier {
}
Future<Account?> update({
Describable? describable,
String? locale,
String? avatarUrl,
String? notificationFrequency,
@@ -105,6 +155,7 @@ class AccountProvider extends ChangeNotifier {
try {
final updated = await AccountService.update(
account!.copyWith(
describable: describable,
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
locale: locale ?? account!.locale,
),
@@ -141,4 +192,26 @@ class AccountProvider extends ChangeNotifier {
rethrow;
}
}
Future<void> forgotPassword(String email) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.forgotPassword(email);
_setResource(_resource.copyWith(isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> resetPassword(String accountId, String token, String newPassword) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.resetPassword(accountId, token, newPassword);
_setResource(_resource.copyWith(isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
}

View File

@@ -5,9 +5,9 @@ import 'package:collection/collection.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/organization/organization.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/service/organization.dart';
import 'package:pshared/service/secure_storage.dart';
import 'package:pshared/utils/exception.dart';
class OrganizationsProvider extends ChangeNotifier {

View File

@@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/service/pfe/service.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/account.dart';
class PfeProvider extends ChangeNotifier {
// The resource now wraps our Account? state along with its loading/error state.
Resource<String?> _resource = Resource(data: null);
Resource<String?> get resource => _resource;
String? get session => _resource.data;
bool get isLoggedIn => session != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
// Private helper to update the resource and notify listeners.
void _setResource(Resource<String?> newResource) {
_resource = newResource;
notifyListeners();
}
Future<String?> login({
required String email,
required String password,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await PfeService.login(email, password);
_setResource(Resource(data: acc, isLoading: false));
return acc;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> logout() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.logout();
_setResource(Resource(data: null, isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
}

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/permission_bound_storable.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/models/permissions/bound/storable.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/template.dart';
import 'package:pshared/utils/exception.dart';
List<T> mergeLists<T>({

View File

@@ -4,10 +4,17 @@ import 'package:share_plus/share_plus.dart';
import 'package:pshared/api/requests/signup.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/requests/change_password.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/requests/password/change.dart';
import 'package:pshared/api/requests/password/forgot.dart';
import 'package:pshared/api/requests/password/reset.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/files.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/http/requests.dart';
@@ -17,9 +24,44 @@ class AccountService {
static final _logger = Logger('service.account');
static const String _objectType = Services.account;
static Future<Account> login(String email, String password, String locale) async {
static Future<LoginOutcome> login(LoginData login) async {
_logger.fine('Logging in');
return AuthorizationService.login(_objectType, email, password, locale);
return AuthorizationService.login(_objectType, login);
}
static Future<void> resendLoginCode(PendingLogin pending, {String? destination}) async {
await getPOSTResponse(
_objectType,
'confirmations/resend',
{
'target': 'login',
if (destination != null) 'destination': destination,
},
authToken: pending.pendingToken.token,
);
}
static Future<Account> confirmLoginCode({
required PendingLogin pending,
required String code,
String? destination,
}) async {
final response = await getPOSTResponse(
_objectType,
'confirmations/verify',
{
'target': 'login',
'code': code,
if (destination != null) 'destination': destination,
'sessionIdentifier': pending.session.toJson(),
},
authToken: pending.pendingToken.token,
);
final loginResponse = LoginResponse.fromJson(response);
await AuthorizationStorage.updateToken(loginResponse.accessToken);
await AuthorizationStorage.updateRefreshToken(loginResponse.refreshToken);
return loginResponse.account.toDomain();
}
static Future<Account> restore() async {
@@ -27,6 +69,7 @@ class AccountService {
}
static Future<void> signup(SignupRequest request) async {
// Use regular HTTP for public signup endpoint (no auth needed)
await getPOSTResponse(_objectType, 'signup', request.toJson());
}
@@ -42,9 +85,20 @@ class AccountService {
static Future<Account> update(Account account) async {
_logger.fine('Patching account ${account.id}');
// Use AuthorizationService for authenticated operations
return _getAccount(AuthorizationService.getPUTResponse(_objectType, '', account.toDTO().toJson()));
}
static Future<void> forgotPassword(String email) async {
_logger.fine('Requesting password reset for email: $email');
await getPUTResponse(_objectType, 'password', ForgotPasswordRequest.build(login: email).toJson());
}
static Future<void> resetPassword(String accountRef, String token, String newPassword) async {
_logger.fine('Resetting password for account: $accountRef');
await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson());
}
static Future<Account> changePassword(String oldPassword, String newPassword) async {
_logger.fine('Changing password');
return _getAccount(AuthorizationService.getPATCHResponse(

View File

@@ -0,0 +1,92 @@
import 'package:logging/logging.dart';
/// Circuit breaker pattern implementation for authentication service failures
class AuthCircuitBreaker {
static final _logger = Logger('service.auth_circuit_breaker');
static int _failureCount = 0;
static DateTime? _lastFailure;
static const int _failureThreshold = 3;
static const Duration _recoveryTime = Duration(minutes: 5);
/// Returns true if the circuit breaker is open (blocking operations)
static bool get isOpen {
if (_failureCount < _failureThreshold) return false;
if (_lastFailure == null) return false;
final isOpen = DateTime.now().difference(_lastFailure!) < _recoveryTime;
if (isOpen) {
_logger.warning('Circuit breaker is OPEN. Failure count: $_failureCount, last failure: $_lastFailure');
}
return isOpen;
}
/// Returns true if the circuit breaker is in half-open state (allowing test requests)
static bool get isHalfOpen {
if (_failureCount < _failureThreshold) return false;
if (_lastFailure == null) return false;
return DateTime.now().difference(_lastFailure!) >= _recoveryTime;
}
/// Executes an operation with circuit breaker protection
static Future<T> execute<T>(Future<T> Function() operation) async {
if (isOpen) {
final timeSinceFailure = _lastFailure != null
? DateTime.now().difference(_lastFailure!)
: Duration.zero;
final timeUntilRecovery = _recoveryTime - timeSinceFailure;
_logger.warning('Circuit breaker blocking operation. Recovery in: ${timeUntilRecovery.inSeconds}s');
throw Exception('Auth service temporarily unavailable. Try again in ${timeUntilRecovery.inMinutes} minutes.');
}
try {
_logger.fine('Executing operation through circuit breaker');
final result = await operation();
_reset();
return result;
} catch (e) {
_recordFailure();
rethrow;
}
}
/// Records a failure and updates the circuit breaker state
static void _recordFailure() {
_failureCount++;
_lastFailure = DateTime.now();
_logger.warning('Auth circuit breaker recorded failure #$_failureCount at $_lastFailure');
if (_failureCount >= _failureThreshold) {
_logger.severe('Auth circuit breaker OPENED after $_failureCount failures');
}
}
/// Resets the circuit breaker to closed state
static void _reset() {
if (_failureCount > 0) {
_logger.info('Auth circuit breaker CLOSED - resetting failure count from $_failureCount to 0');
}
_failureCount = 0;
_lastFailure = null;
}
/// Manual reset (for testing or administrative purposes)
static void manualReset() {
_logger.info('Auth circuit breaker manually reset');
_reset();
}
/// Get current status for debugging
static Map<String, dynamic> getStatus() {
return {
'failureCount': _failureCount,
'lastFailure': _lastFailure?.toIso8601String(),
'isOpen': isOpen,
'isHalfOpen': isHalfOpen,
'threshold': _failureThreshold,
'recoveryTime': _recoveryTime.inSeconds,
};
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:math';
import 'package:logging/logging.dart';
import 'package:pshared/utils/exception.dart';
class RetryHelper {
static final _logger = Logger('auth.retry');
/// Executes an operation with exponential backoff retry logic
static Future<T> withExponentialBackoff<T>(
Future<T> Function() operation, {
int maxRetries = 3,
Duration initialDelay = const Duration(milliseconds: 500),
double backoffMultiplier = 2.0,
Duration maxDelay = const Duration(seconds: 30),
bool Function(Exception)? shouldRetry,
}) async {
Exception? lastException;
// Total attempts = initial attempt + maxRetries
final totalAttempts = maxRetries + 1;
for (int attempt = 1; attempt <= totalAttempts; attempt++) {
try {
_logger.fine('Attempting operation (attempt $attempt/$totalAttempts)');
return await operation();
} catch (e) {
lastException = toException(e);
// Don't retry if we've reached max attempts
if (attempt == totalAttempts) {
_logger.warning('Operation failed after $totalAttempts attempts: $lastException');
rethrow;
}
// Check if we should retry this specific error
if (shouldRetry != null && !shouldRetry(lastException)) {
_logger.fine('Operation failed with non-retryable error: $lastException');
rethrow;
}
// Calculate delay with exponential backoff
final delayMs = min(
initialDelay.inMilliseconds * pow(backoffMultiplier, attempt - 1).toInt(),
maxDelay.inMilliseconds,
);
final delay = Duration(milliseconds: delayMs);
_logger.fine('Operation failed (attempt $attempt), retrying in ${delay.inMilliseconds}ms: $lastException');
await Future.delayed(delay);
}
}
// This should never be reached due to rethrow above, but just in case
throw lastException ?? Exception('Retry logic error');
}
/// Determines if an error is retryable (network/temporary errors)
static bool isRetryableError(Exception error) {
final errorString = error.toString().toLowerCase();
// Network connectivity issues
if (errorString.contains('socket') ||
errorString.contains('connection') ||
errorString.contains('timeout') ||
errorString.contains('network')) {
return true;
}
// Server temporary errors (5xx)
if (errorString.contains('500') ||
errorString.contains('502') ||
errorString.contains('503') ||
errorString.contains('504')) {
return true;
}
// Rate limiting
if (errorString.contains('429')) {
return true;
}
return false;
}
}

View File

@@ -1,20 +1,52 @@
import 'package:logging/logging.dart';
import 'package:pshared/api/errors/upload_failed.dart';
import 'package:pshared/api/errors/authorization_failed.dart';
import 'package:pshared/api/requests/login.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/api/responses/login_pending.dart';
import 'package:pshared/config/web.dart';
import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/models/auth/pending_login.dart';
import 'package:pshared/models/session_identifier.dart';
import 'package:pshared/service/authorization/circuit_breaker.dart';
import 'package:pshared/service/authorization/retry_helper.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/authorization/token.dart';
import 'package:pshared/service/device_id.dart';
import 'package:pshared/utils/exception.dart';
import 'package:pshared/utils/http/requests.dart' as httpr;
/// AuthorizationService provides centralized authorization management
/// with token refresh, retry logic, and circuit breaker patterns
class AuthorizationService {
static final _logger = Logger('service.authorization');
static final _logger = Logger('service.authorization.auth_service');
static Future<LoginOutcome> login(String service, LoginData login) async {
_logger.fine('Logging in ${login.login} with ${login.locale} locale');
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
service,
'/login',
LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(),
);
if (response.containsKey('refreshToken')) {
return LoginOutcome.completed((await completeLogin(response)).account.toDomain());
}
if (response.containsKey('pendingToken')) {
final pending = PendingLogin.fromResponse(
PendingLoginResponse.fromJson(response),
session: SessionIdentifier(clientId: Constants.clientId, deviceId: deviceId),
);
return LoginOutcome.pending(pending);
}
throw AuthenticationFailedException('Unexpected login response', Exception(response.toString()));
}
static Future<void> _updateAccessToken(AccountResponse response) async {
await AuthorizationStorage.updateToken(response.accessToken);
@@ -31,59 +63,88 @@ class AuthorizationService {
return lr;
}
static Future<Account> login(String service, String email, String password, String locale) async {
_logger.fine('Logging in $email with $locale locale');
final deviceId = await DeviceIdManager.getDeviceId();
final response = await httpr.getPOSTResponse(
service,
'/login',
LoginRequest(
login: email.toLowerCase(),
password: password,
locale: locale,
deviceId: deviceId,
clientId: Constants.clientId,
).toJson());
return (await _completeLogin(response)).account.toDomain();
}
static Future<LoginResponse> completeLogin(Map<String, dynamic> response) => _completeLogin(response);
static Future<Account> restore() async {
return (await TokenService.rotateRefreshToken()).account.toDomain();
return (await TokenService.refreshAccessToken()).account.toDomain();
}
static Future<void> logout() async {
return AuthorizationStorage.removeTokens();
}
static Future<Map<String, dynamic>> _authenticatedRequest(
String service,
String url,
Future<Map<String, dynamic>> Function(String, String, Map<String, dynamic>, {String? authToken}) requestType,
{Map<String, dynamic>? body}) async {
final accessToken = await TokenService.getAccessToken();
return requestType(service, url, body ?? {}, authToken: accessToken);
}
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPOSTResponse, body: body);
// Original AuthorizationService methods - keeping the interface unchanged
static Future<Map<String, dynamic>> getGETResponse(String service, String url) async {
final accessToken = await TokenService.getAccessToken();
return httpr.getGETResponse(service, url, authToken: accessToken);
final token = await TokenService.getAccessTokenSafe();
return httpr.getGETResponse(service, url, authToken: token);
}
static Future<Map<String, dynamic>> getPUTResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPUTResponse, body: body);
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPOSTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getPATCHResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getPATCHResponse, body: body);
static Future<Map<String, dynamic>> getPUTResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPUTResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getDELETEResponse(String service, String url, Map<String, dynamic> body) async => _authenticatedRequest(service, url, httpr.getDELETEResponse, body: body);
static Future<Map<String, dynamic>> getPATCHResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getPATCHResponse(service, url, body, authToken: token);
}
static Future<Map<String, dynamic>> getDELETEResponse(String service, String url, Map<String, dynamic> body) async {
final token = await TokenService.getAccessTokenSafe();
return httpr.getDELETEResponse(service, url, body, authToken: token);
}
static Future<String> getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List<int> bytes) async {
final accessToken = await TokenService.getAccessToken();
final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: accessToken);
final token = await TokenService.getAccessTokenSafe();
final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: token);
if (res == null) {
throw ErrorUploadFailed();
throw Exception('Upload failed');
}
return res.url;
}
static Future<bool> isAuthorizationStored() async => AuthorizationStorage.isAuthorizationStored();
/// Execute an operation with automatic token management and retry logic
static Future<T> executeWithAuth<T>(
Future<T> Function() operation,
String description, {
int? maxRetries,
}) async => AuthCircuitBreaker.execute(() async => RetryHelper.withExponentialBackoff(
operation,
maxRetries: maxRetries ?? 3,
initialDelay: Duration(milliseconds: 100),
maxDelay: Duration(seconds: 5),
shouldRetry: (error) => RetryHelper.isRetryableError(error),
));
/// Handle 401 unauthorized errors with automatic token recovery
static Future<T> handleUnauthorized<T>(
Future<T> Function() operation,
String description,
) async {
_logger.warning('Handling unauthorized error with token recovery: $description');
return executeWithAuth(
() async {
try {
// Attempt token recovery first
await TokenService.handleUnauthorized();
// Retry the original operation
return await operation();
} catch (e) {
_logger.severe('Token recovery failed', e);
throw AuthenticationFailedException('Token recovery failed', toException(e));
}
},
'unauthorized recovery: $description',
);
}
}

View File

@@ -20,6 +20,34 @@ class AuthorizationStorage {
return TokenData.fromJson(jsonDecode(tokenJson));
}
static Future<bool> _checkTokenUsable(String keyName) async {
final hasKey = await SecureStorageService.containsKey(keyName);
if (!hasKey) return false;
try {
final tokenData = await _getTokenData(keyName);
return tokenData.expiration.isAfter(DateTime.now());
} catch (e, st) {
_logger.warning('Error reading token from $keyName: $e', e, st);
rethrow;
}
}
static Future<bool> isAuthorizationStored() async {
_logger.fine('Checking if authorization is stored');
final accessUsable = await _checkTokenUsable(Constants.accessTokenStorageKey);
if (accessUsable) return true;
final refreshUsable = await _checkTokenUsable(Constants.refreshTokenStorageKey);
if (refreshUsable) return true;
return false;
}
static Future<TokenData> getAccessToken() async {
_logger.fine('Getting access token');
return _getTokenData(Constants.accessTokenStorageKey);

View File

@@ -1,20 +1,25 @@
import 'package:logging/logging.dart';
import 'package:pshared/api/errors/authorization_failed.dart';
import 'package:pshared/api/errors/unauthorized.dart';
import 'package:pshared/api/requests/tokens/access_refresh.dart';
import 'package:pshared/api/requests/tokens/refresh_rotate.dart';
import 'package:pshared/api/responses/account.dart';
import 'package:pshared/api/responses/error/server.dart';
import 'package:pshared/api/responses/login.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/service/authorization/circuit_breaker.dart';
import 'package:pshared/service/authorization/retry_helper.dart';
import 'package:pshared/service/authorization/storage.dart';
import 'package:pshared/service/authorization/token_mutex.dart';
import 'package:pshared/service/device_id.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/exception.dart';
import 'package:pshared/utils/http/requests.dart';
class TokenService {
static final _logger = Logger('service.authorization.token');
static const String _objectType = Services.account;
@@ -26,7 +31,11 @@ class TokenService {
static Future<String> getAccessToken() async {
TokenData token = await AuthorizationStorage.getAccessToken();
if (_isTokenExpiringSoon(token, const Duration(hours: 4))) {
token = (await _refreshAccessToken()).accessToken;
// Use mutex to prevent concurrent refresh operations
final refreshedToken = await TokenRefreshMutex().executeRefresh(() async {
return (await refreshAccessToken()).accessToken.token;
});
return refreshedToken;
}
return token.token;
}
@@ -36,13 +45,13 @@ class TokenService {
await AuthorizationStorage.updateRefreshToken(response.refreshToken);
}
static Future<AccountResponse> _refreshAccessToken() async {
static Future<AccountResponse> refreshAccessToken() async {
_logger.fine('Refreshing access token...');
final deviceId = await DeviceIdManager.getDeviceId();
final refresh = await AuthorizationStorage.getRefreshToken();
if (_isTokenExpiringSoon(refresh, const Duration(days: 7))) {
return await rotateRefreshToken();
return await _rotateRefreshToken();
}
final response = await getPOSTResponse(
@@ -60,7 +69,7 @@ class TokenService {
return accountResp;
}
static Future<LoginResponse> rotateRefreshToken() async {
static Future<LoginResponse> _rotateRefreshToken() async {
_logger.fine('Rotating refresh token...');
final refresh = await AuthorizationStorage.getRefreshToken();
@@ -82,4 +91,89 @@ class TokenService {
return loginResponse;
}
/// Enhanced method to handle unexpected 401 errors with fallback logic
static Future<void> handleUnauthorized() async {
_logger.warning('Handling unexpected 401 unauthorized error');
return AuthCircuitBreaker.execute(() async {
return RetryHelper.withExponentialBackoff(
() async {
try {
// Try refresh first (faster)
final currentRefresh = await AuthorizationStorage.getRefreshToken();
if (!_isTokenExpiringSoon(currentRefresh, const Duration(days: 1))) {
_logger.fine('Attempting access token refresh for 401 recovery');
await TokenRefreshMutex().executeRefresh(() async {
await refreshAccessToken();
return 'refreshed';
});
return;
}
// Fallback to rotation if refresh token expiring soon
_logger.fine('Attempting refresh token rotation for 401 recovery');
await TokenRefreshMutex().executeRotation(() async {
await _rotateRefreshToken();
});
} catch (e) {
_logger.severe('Token recovery failed: $e');
throw AuthenticationFailedException('Token recovery failed', toException(e));
}
},
maxRetries: 2,
shouldRetry: (error) {
// Only retry on network errors, not auth errors
return RetryHelper.isRetryableError(error) && !_isAuthError(error);
},
);
});
}
/// Enhanced getAccessToken with better error handling
static Future<String> getAccessTokenSafe() async {
return AuthCircuitBreaker.execute(() async {
return RetryHelper.withExponentialBackoff(
() async {
TokenData token = await AuthorizationStorage.getAccessToken();
if (_isTokenExpiringSoon(token, const Duration(hours: 4))) {
// Use mutex to prevent concurrent refresh operations
final refreshedToken = await TokenRefreshMutex().executeRefresh(() async {
return (await refreshAccessToken()).accessToken.token;
});
return refreshedToken;
}
return token.token;
},
maxRetries: 2,
shouldRetry: (error) => RetryHelper.isRetryableError(error),
);
});
}
/// Check if error is authentication-related (non-retryable)
static bool _isAuthError(Exception error) {
if (error is ErrorUnauthorized || error is AuthenticationFailedException) {
return true;
}
if (error is ErrorResponse && error.code == 401) {
return true;
}
final errorString = error.toString().toLowerCase();
return errorString.contains('unauthorized') ||
errorString.contains('401') ||
errorString.contains('authentication') ||
errorString.contains('token');
}
/// Get circuit breaker status for debugging
static Map<String, dynamic> getAuthStatus() {
return {
'circuitBreaker': AuthCircuitBreaker.getStatus(),
'tokenMutex': TokenRefreshMutex().getStatus(),
'timestamp': DateTime.now().toIso8601String(),
};
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:logging/logging.dart';
/// Mutex to prevent concurrent token refresh operations
/// This ensures only one refresh operation happens at a time,
/// preventing race conditions during app startup when multiple
/// providers try to refresh tokens simultaneously.
class TokenRefreshMutex {
static final _instance = TokenRefreshMutex._();
factory TokenRefreshMutex() => _instance;
TokenRefreshMutex._();
static final _logger = Logger('service.authorization.token_mutex');
Completer<String>? _currentRefresh;
Completer<void>? _currentRotation;
/// Execute a token refresh operation with mutex protection
/// If another refresh is in progress, wait for it to complete
Future<String> executeRefresh(Future<String> Function() refreshOperation) async {
if (_currentRefresh != null) {
_logger.fine('Token refresh already in progress, waiting for completion');
return await _currentRefresh!.future;
}
_logger.fine('Starting new token refresh operation');
_currentRefresh = Completer<String>();
try {
final result = await refreshOperation();
if (_currentRefresh != null) {
_currentRefresh!.complete(result);
_logger.fine('Token refresh completed successfully');
}
return result;
} catch (e, st) {
_logger.warning('Token refresh failed', e, st);
if (_currentRefresh != null) {
_currentRefresh!.completeError(e, st);
}
rethrow;
} finally {
_currentRefresh = null;
}
}
/// Execute a token rotation operation with mutex protection
/// If another rotation is in progress, wait for it to complete
Future<void> executeRotation(Future<void> Function() rotationOperation) async {
if (_currentRotation != null) {
_logger.fine('Token rotation already in progress, waiting for completion');
return await _currentRotation!.future;
}
_logger.fine('Starting new token rotation operation');
_currentRotation = Completer<void>();
try {
await rotationOperation();
if (_currentRotation != null) {
_currentRotation!.complete();
_logger.fine('Token rotation completed successfully');
}
} catch (e, st) {
_logger.warning('Token rotation failed', e, st);
if (_currentRotation != null) {
_currentRotation!.completeError(e, st);
}
rethrow;
} finally {
_currentRotation = null;
}
}
/// Check if a refresh operation is currently in progress
bool get isRefreshInProgress => _currentRefresh != null;
/// Check if a rotation operation is currently in progress
bool get isRotationInProgress => _currentRotation != null;
/// Get current status for debugging
Map<String, dynamic> getStatus() {
return {
'refreshInProgress': isRefreshInProgress,
'rotationInProgress': isRotationInProgress,
'timestamp': DateTime.now().toIso8601String(),
};
}
/// Force reset the mutex (for testing or emergency situations)
void forceReset() {
_logger.warning('Force resetting token refresh mutex');
if (_currentRefresh != null && !_currentRefresh!.isCompleted) {
_currentRefresh!.completeError(Exception('Mutex force reset'));
}
if (_currentRotation != null && !_currentRotation!.isCompleted) {
_currentRotation!.completeError(Exception('Mutex force reset'));
}
_currentRefresh = null;
_currentRotation = null;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:shared_preferences/shared_preferences.dart';
class SecureStorageService {
static Future<String?> get(String key) async {
final prefs = await SharedPreferences.getInstance();
@@ -18,6 +19,11 @@ class SecureStorageService {
return _setImp(prefs, key, value);
}
static Future<bool> containsKey(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.containsKey(key);
}
static Future<void> delete(String key) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);

View File

@@ -6,27 +6,13 @@ class Services {
static const String invitations = 'invitations';
static const String organization = 'organizations';
static const String permission = 'permissions';
static const String project = 'projects';
static const String pgroup = 'priority_groups';
static const String priorities = 'priorities';
static const String reactions = 'reactions';
static const String storage = 'storage';
static const String taskStatus = 'statuses';
static const String tasks = 'tasks';
static const String amplitude = 'amplitude';
static const String automations = 'automation';
static const String changes = 'changes';
static const String clients = 'clients';
static const String invoices = 'invoices';
static const String logo = 'logo';
static const String notifications = 'notifications';
static const String policies = 'policies';
static const String properties = 'properties';
static const String refreshTokens = 'refresh_tokens';
static const String roles = 'roles';
static const String steps = 'steps';
static const String teams = 'teams';
static const String workflows = 'workflows';
static const String workspaces = 'workspaces';
}