Start writing the web app
All checks were successful
ci/woodpecker/push/build Pipeline was successful

Signed-off-by: Nikolai Rodionov <allanger@posteo.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-05-19 13:37:41 +02:00
parent 0c5b657a2f
commit 09df205fdb
76 changed files with 1961 additions and 117 deletions

View File

@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc_web.dart';
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
import 'package:softplayer_web/core/grpc/grpc_client.dart';
final accountsGrpcProvider = Provider<AccountsGrpcRepository>((ref) {
return AccountsGrpcRepository(ref.watch(accountsServiceClientProvider));
});
class AccountsGrpcRepository {
AccountsGrpcRepository(this._client);
final AccountsServiceClient _client;
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc_web.dart';
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
import 'package:softplayer_web/core/grpc/grpc_client.dart';
final publicAccountsGrpcProvider = Provider<PublicAccountsGrpcRepository>((
ref,
) {
return PublicAccountsGrpcRepository(
ref.watch(publicAccountsServiceClientProvider),
);
});
class PublicAccountsGrpcRepository {
PublicAccountsGrpcRepository(this._client);
final PublicAccountsServiceClient _client;
ResponseFuture<SignInResponse> signIn(SignInRequest req) {
try {
final response = _client.signIn(req);
return response;
} catch (error) {
rethrow;
}
}
ResponseFuture<SignUpResponse> signUp(SignUpRequest req) {
try {
final response = _client.signUp(req);
return response;
} catch (error) {
rethrow;
}
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc_web.dart';
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
import 'package:softplayer_web/core/grpc/grpc_client.dart';
final refreshSessionGrpcProvider = Provider<RefreshSessionGrpcRepository>((
ref,
) {
return RefreshSessionGrpcRepository(
ref.watch(refreshSessionServiceClientProvider),
);
});
class RefreshSessionGrpcRepository {
final RefreshSessionServiceClient _client;
RefreshSessionGrpcRepository(this._client);
ResponseFuture<RefreshSessionResponse> refreshSession(String refreshToken) {
try {
final response = _client.refreshSession(
RefreshSessionRequest(refreshToken: refreshToken),
);
return response;
} catch (error) {
rethrow;
}
}
}

22
lib/core/api/v1/test.dart Normal file
View File

@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc_web.dart';
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
import 'package:softplayer_web/core/grpc/grpc_client.dart';
final testGrpcProvider = Provider<TestGrpcRepository>((ref) {
return TestGrpcRepository(ref.watch(testServiceClientProvider));
});
class TestGrpcRepository {
TestGrpcRepository(this._client);
final TestServiceClient _client;
ResponseFuture<PongResponse> pong() {
try {
final response = _client.pong(PongRequest());
return response;
} catch (error) {
rethrow;
}
}
}

View File

@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:grpc/grpc.dart';
class AuthInterceptor extends ClientInterceptor {
final String Function() getAccessToken;
final String Function() getRefreshToken;
AuthInterceptor({
required this.getAccessToken,
required this.getRefreshToken,
});
bool _isPublicEndpoint(String path) {
return path.contains('/Public');
}
bool _isRefreshEndpoint(String path) {
return path.contains('/RefreshToken');
}
Map<String, String> _buildMetadata(String path) {
final metadata = <String, String>{};
// Public endpoints → no token
if (_isPublicEndpoint(path)) {
log("Public endpoint, no tokens needed");
return metadata;
}
String? token;
// Refresh endpoint → use refresh token
if (_isRefreshEndpoint(path)) {
log("Adding a refresh token to the metadata");
token = getRefreshToken();
} else {
log("Adding an acces token to the metadata");
token = getAccessToken();
}
if (token.isNotEmpty) {
metadata['authorization'] = 'Bearer $token';
}
return metadata;
}
@override
ResponseStream<R> interceptStreaming<Q, R>(
ClientMethod<Q, R> method,
Stream<Q> requests,
CallOptions options,
ClientStreamingInvoker<Q, R> invoker,
) {
// TODO: implement interceptStreaming
throw UnimplementedError();
}
@override
ResponseFuture<R> interceptUnary<Q, R>(
ClientMethod<Q, R> method,
Q request,
CallOptions options,
ClientUnaryInvoker<Q, R> invoker,
) {
final modifiedOptions = options.mergedWith(
CallOptions(metadata: _buildMetadata(method.path)),
);
return super.interceptUnary(method, request, modifiedOptions, invoker);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grpc/grpc_web.dart';
final grpcChannelProvider = Provider<GrpcWebClientChannel>((ref) {
String backendURL = dotenv.env['SOFTPLAYER_BACKEND_URL']!;
final GrpcWebClientChannel channel = GrpcWebClientChannel.xhr(
Uri.parse(backendURL),
);
ref.onDispose(() async {
await channel.shutdown();
});
return channel;
});

View File

@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
import 'package:softplayer_web/core/grpc/grpc_auth_interceptor.dart';
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
import 'grpc_channel_provider.dart';
final testServiceClientProvider = Provider<TestServiceClient>((ref) {
final channel = ref.watch(grpcChannelProvider);
final tokenState = ref.read(tokensControllerProvider).value;
if (tokenState == null) {
throw Exception("Token state is not initialized");
}
return TestServiceClient(
channel,
interceptors: [
AuthInterceptor(
getAccessToken: tokenState.getAccessToken,
getRefreshToken: tokenState.getRefreshToken,
),
],
);
});
final accountsServiceClientProvider = Provider<AccountsServiceClient>((ref) {
final channel = ref.watch(grpcChannelProvider);
final tokenState = ref.read(tokensControllerProvider).value;
if (tokenState == null) {
throw Exception("Token state is not initialized");
}
return AccountsServiceClient(
channel,
interceptors: [
AuthInterceptor(
getAccessToken: tokenState.getAccessToken,
getRefreshToken: tokenState.getRefreshToken,
),
],
);
});
final publicAccountsServiceClientProvider =
Provider<PublicAccountsServiceClient>((ref) {
final channel = ref.watch(grpcChannelProvider);
return PublicAccountsServiceClient(channel);
});
final refreshSessionServiceClientProvider =
Provider<RefreshSessionServiceClient>((ref) {
final channel = ref.watch(grpcChannelProvider);
return RefreshSessionServiceClient(channel);
});

View File

@@ -0,0 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
import 'package:softplayer_web/features/authorization/presentation/authorization_page.dart';
import 'package:softplayer_web/features/dashboard/presentation/dashboard_page.dart';
final goRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authorizationControllerProvider);
final _tokenCtrl = ref.watch(tokensControllerProvider);
// If not authorized, redirect to the auth page, otherwise dashboard
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthorized = authState.requireValue.isAuthorized;
if (!isAuthorized) {
return "/auth";
}
return "/";
},
routes: [
GoRoute(path: '/', builder: (context, state) => const DashboardPage()),
GoRoute(
path: '/auth',
builder: (context, state) => const AuthorizationPage(),
),
],
);
});

View File

@@ -0,0 +1,167 @@
import 'dart:developer';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pb.dart';
import 'package:softplayer_web/core/api/v1/accounts.dart';
import 'package:softplayer_web/core/api/v1/refresh_session.dart';
import 'package:softplayer_web/core/grpc/grpc_client.dart';
import 'package:softplayer_web/core/tokens/data/token_storage_repository.dart';
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
class TokenState {
final String? accessToken;
final String? refreshToken;
const TokenState({this.accessToken, this.refreshToken});
TokenState copyWith({String? accessToken, String? refreshToken}) {
return TokenState(
refreshToken: refreshToken ?? this.refreshToken,
accessToken: accessToken ?? this.accessToken,
);
}
// Get an access token from the state
String getAccessToken() {
return accessToken ?? "";
}
// Get a refresh token from the state
String getRefreshToken() {
return refreshToken ?? "";
}
}
const accessTokenHeader = "x-access-token";
const refreshTokenHeader = "x-refresh-token";
class Tokens {
final String accessToken;
final String refreshToken;
const Tokens({required this.accessToken, required this.refreshToken});
factory Tokens.fromProto(TokenPair pair) {
return Tokens(
accessToken: pair.accessToken,
refreshToken: pair.refreshToken,
);
}
}
final tokensControllerProvider =
AsyncNotifierProvider<TokensController, TokenState>(TokensController.new);
class TokensController extends AsyncNotifier<TokenState> {
@override
Future<TokenState> build() async {
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
final accessToken = await tokenRepo.getAccessToken();
final refreshToken = await tokenRepo.getRefreshToken();
// If refresh token is not valid, we just drop the session,
// even if there is an active access token, and return
// an empty state
if (!verifyJWT(refreshToken)) {
log("Refresh token is not valid, logging out");
await tokenRepo.clearStorage();
return TokenState();
}
// If access token is not valid, refresh it using the refresh token
if (verifyJWT(accessToken)) {
log("Access token is valid");
return TokenState(refreshToken: refreshToken, accessToken: accessToken);
}
log("Only refresh token is valid");
return TokenState(refreshToken: refreshToken);
}
Future<void> resetTokens() async {
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
await tokenRepo.clearStorage();
state = await AsyncValue.guard(() async {
return TokenState();
});
}
// Store an access token to the storage and save it to the state
Future<void> writeAccessToken(String token) async {
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
await tokenRepo.storeAccessToken(token);
state = await AsyncValue.guard(() async {
return state.value!.copyWith(accessToken: token);
});
}
// Store a pair of tokens to the storage and refresh the state
Future<void> writeTokenPair(Tokens pair) async {
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
log("Writing tokens");
await tokenRepo.storeRefreshToken(pair.refreshToken);
await tokenRepo.storeAccessToken(pair.accessToken);
state = await AsyncValue.guard(() async {
return state.value!.copyWith(
accessToken: pair.accessToken,
refreshToken: pair.refreshToken,
);
});
}
Future<void> checkTokens() async {
final currentState = state.value;
if (currentState == null ||
currentState.accessToken == null ||
currentState.refreshToken == null) {
log("Trying to get tokens from the storage");
state = await AsyncValue.guard(() async {
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
return TokenState(
accessToken: await tokenRepo.getAccessToken(),
refreshToken: await tokenRepo.getRefreshToken(),
);
});
}
log("checking tokens");
final inMemAccessToken = state.value!.getAccessToken();
// If we have a valid token in memory, nothing must be done
// TODO: Store the expiration in memory, to make it cheaper to check
if (verifyJWT(inMemAccessToken)) {
return;
} else {
log("Access it not valid");
final inMemRefreshToken = state.value!.getRefreshToken();
if (verifyJWT(inMemRefreshToken)) {
log("Trying to refresh");
try {
final refreshSessionGrpc = ref.read(refreshSessionGrpcProvider);
log("Trying to refresh");
final response = await refreshSessionGrpc.refreshSession(
inMemRefreshToken,
);
await writeTokenPair(Tokens.fromProto(response.tokenPair));
return;
} catch (e) {
log(e.toString());
}
}
}
log("Refrsh it not valid");
await resetTokens();
ref.read(authorizationControllerProvider.notifier).logout();
}
bool verifyJWT(String? token) {
if (token == null || token.isEmpty) {
return false;
}
try {
bool isExpired = JwtDecoder.isExpired(token);
return !isExpired;
} catch (_) {
rethrow;
}
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const accessTokenKey = "access_token";
const refreshTokenKey = "refresh_token";
final tokenStorageRepositoryProvider = Provider<TokenStorageRepository>((ref) {
return TokenStorageRepository();
});
// Secure storage backend for storing tokens in the browser
class TokenStorageRepository {
static const _storage = FlutterSecureStorage();
TokenStorageRepository();
// Store the refresh token in the storage
Future<void> storeRefreshToken(String token) async {
await _storage.write(key: refreshTokenKey, value: token);
}
// Store the access token in the storage
Future<void> storeAccessToken(String token) async {
await _storage.write(key: accessTokenKey, value: token);
}
// Get the acccess token from the storage
Future<String?> getAccessToken() async {
final token = await _storage.read(key: accessTokenKey);
return token;
}
// Get the refresh token from the storage
Future<String?> getRefreshToken() async {
final token = await _storage.read(key: refreshTokenKey);
return token;
}
Future<void> clearStorage() async {
await _storage.delete(key: refreshTokenKey);
await _storage.delete(key: accessTokenKey);
}
}