forked from baron/baron-sso
이메일/비밀번호 로그인 기능 구현
This commit is contained in:
@@ -228,6 +228,7 @@ func main() {
|
|||||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
|
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||||
auth.Post("/sms", authHandler.SendSms)
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
auth.Post("/verify-sms", authHandler.VerifySms)
|
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||||
|
|||||||
@@ -667,6 +667,42 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// PasswordLogin - Authenticate a user with login ID and password.
|
||||||
|
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
slog.Error("[PasswordLogin] Body parse error", "error", err)
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[PasswordLogin] Attempting to login", "loginID", req.LoginID)
|
||||||
|
|
||||||
|
if h.DescopeClient == nil {
|
||||||
|
slog.Error("[PasswordLogin] Descope Client is nil!")
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in using Descope
|
||||||
|
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("[PasswordLogin] Descope sign-in failed", "loginID", req.LoginID, "error", err)
|
||||||
|
// It's good practice to return a generic error message for security.
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[PasswordLogin] Success", "loginID", req.LoginID)
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"sessionJwt": authInfo.SessionToken.JWT,
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
||||||
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||||
pendingRef := GenerateSecureToken(16)
|
pendingRef := GenerateSecureToken(16)
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'loginId': loginId,
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
final errorBody = jsonDecode(response.body);
|
||||||
|
throw Exception(errorBody['error'] ?? 'Failed to login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> sendSms(String phoneNumber) async {
|
static Future<void> sendSms(String phoneNumber) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
final TextEditingController _idController = TextEditingController();
|
final TextEditingController _linkIdController = TextEditingController();
|
||||||
final TextEditingController _smsCodeController = TextEditingController(); // Keep if needed for verification inputs later? Actually not used in link flow.
|
final TextEditingController _passwordLoginIdController = TextEditingController();
|
||||||
bool _smsSent = false;
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
|
|
||||||
// QR Login Variables
|
// QR Login Variables
|
||||||
@@ -40,7 +40,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
|
||||||
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_tabController.addListener(_handleTabSelection);
|
||||||
|
|
||||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||||
@@ -92,9 +93,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTabSelection() {
|
void _handleTabSelection() {
|
||||||
if (_tabController.index == 1 && _qrPendingRef == null) {
|
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
|
||||||
|
if (_tabController.index == 2 && _qrPendingRef == null) {
|
||||||
_startQrFlow();
|
_startQrFlow();
|
||||||
} else if (_tabController.index != 1) {
|
} else if (_tabController.index != 2) {
|
||||||
_stopQrPolling();
|
_stopQrPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,28 +232,56 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSuccessDialog() {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => const AlertDialog(
|
|
||||||
title: Text("Authentication Successful"),
|
|
||||||
content: Text("You can close this tab and return to the application."),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopQrPolling();
|
_stopQrPolling();
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
_idController.dispose();
|
_linkIdController.dispose();
|
||||||
_smsCodeController.dispose();
|
_passwordLoginIdController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
// 이메일/비밀번호 로그인 처리
|
||||||
final input = _idController.text.trim();
|
Future<void> _handlePasswordLogin() async {
|
||||||
|
final loginId = _passwordLoginIdController.text.trim();
|
||||||
|
final password = _passwordController.text.trim();
|
||||||
|
if (loginId.isEmpty || password.isEmpty) {
|
||||||
|
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 인디케이터 표시
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await AuthProxyService.loginWithPassword(loginId, password);
|
||||||
|
final jwt = res['sessionJwt'];
|
||||||
|
if (jwt != null && mounted) {
|
||||||
|
Navigator.of(context).pop(); // 로딩 닫기
|
||||||
|
|
||||||
|
final displayName = _getLoginIdFromJwt(jwt);
|
||||||
|
final dummyUser = DescopeUser(
|
||||||
|
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||||
|
);
|
||||||
|
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||||
|
Descope.sessionManager.manageSession(session);
|
||||||
|
|
||||||
|
_onLoginSuccess(jwt);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
|
||||||
|
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 링크 전송 처리
|
||||||
|
Future<void> _handleLinkLogin() async {
|
||||||
|
final input = _linkIdController.text.trim();
|
||||||
if (input.isEmpty) return;
|
if (input.isEmpty) return;
|
||||||
|
|
||||||
String loginId = input;
|
String loginId = input;
|
||||||
@@ -340,26 +370,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
final displayName = _getLoginIdFromJwt(jwt);
|
final displayName = _getLoginIdFromJwt(jwt);
|
||||||
// Descope SDK 세션 강제 주입
|
// Descope SDK 세션 강제 주입
|
||||||
// Note: DescopeUser in 0.9.11 requires 18 positional arguments.
|
|
||||||
final dummyUser = DescopeUser(
|
final dummyUser = DescopeUser(
|
||||||
'unknown', // userId
|
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||||
[], // loginIds
|
|
||||||
0, // createdAt
|
|
||||||
displayName, // name
|
|
||||||
null, // picture (Uri?)
|
|
||||||
'', // email
|
|
||||||
false, // isVerifiedEmail
|
|
||||||
'', // phone
|
|
||||||
false, // isVerifiedPhone
|
|
||||||
{}, // customAttributes
|
|
||||||
'', // givenName
|
|
||||||
'', // middleName
|
|
||||||
'', // familyName
|
|
||||||
false, // hasPassword
|
|
||||||
'enabled', // status
|
|
||||||
[], // roleNames
|
|
||||||
[], // ssoAppIds
|
|
||||||
[], // oauthProviders (List<String>)
|
|
||||||
);
|
);
|
||||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||||
Descope.sessionManager.manageSession(session);
|
Descope.sessionManager.manageSession(session);
|
||||||
@@ -397,38 +409,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
void _logTokenDetails(String jwt) {
|
void _logTokenDetails(String jwt) {
|
||||||
try {
|
try {
|
||||||
// JWT는 세 부분(Header, Payload, Signature)이 '.'으로 구분된 문자열입니다. 이를 분리합니다.
|
|
||||||
final parts = jwt.split('.');
|
final parts = jwt.split('.');
|
||||||
// 세 부분으로 정확히 나뉘지 않았다면 유효한 JWT가 아니므로 중단합니다.
|
|
||||||
if (parts.length != 3) return;
|
if (parts.length != 3) return;
|
||||||
|
|
||||||
// JWT의 두 번째 부분(Payload)은 Base64Url로 인코딩된 JSON 데이터입니다.
|
|
||||||
// 1. Base64Url 문자열을 디코딩하여 바이트 배열로 변환합니다.
|
|
||||||
// normalize()는 Base64 패딩(=) 문제를 처리해줍니다.
|
|
||||||
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
|
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
|
||||||
// 2. 바이트 배열을 UTF-8 형식의 일반 문자열(JSON)로 변환합니다.
|
|
||||||
final payloadJson = utf8.decode(decodedPayload);
|
final payloadJson = utf8.decode(decodedPayload);
|
||||||
// 3. JSON 문자열을 Dart에서 사용할 수 있는 Map 객체로 변환합니다.
|
|
||||||
final data = json.decode(payloadJson) as Map<String, dynamic>;
|
final data = json.decode(payloadJson) as Map<String, dynamic>;
|
||||||
|
|
||||||
// [FIX] 'exp'는 int 또는 double일 수 있으므로, 안전하게 num으로 처리합니다.
|
|
||||||
final accessExpValue = data['exp'] as num?;
|
final accessExpValue = data['exp'] as num?;
|
||||||
// 'exp' (Expiration Time) 필드는 Access Token의 만료 시간을 나타냅니다. Unix 타임스탬프(초 단위) 값입니다.
|
|
||||||
// 이 값을 Dart의 DateTime 객체로 변환합니다. (1000을 곱해 밀리초 단위로 만듦)
|
|
||||||
final accessExp = accessExpValue != null
|
final accessExp = accessExpValue != null
|
||||||
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
|
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
|
||||||
: 'N/A';
|
: 'N/A';
|
||||||
// 'rexp' (Refresh Expiration) 필드는 Descope가 사용하는 커스텀 필드로, Refresh Token의 만료 시간을 ISO 8601 형식의 문자열로 나타냅니다.
|
|
||||||
final refreshExp = data['rexp'] ?? 'N/A';
|
final refreshExp = data['rexp'] ?? 'N/A';
|
||||||
|
|
||||||
// 확인된 만료 시간 정보들을 디버그 콘솔에 출력합니다.
|
|
||||||
debugPrint("""
|
debugPrint("""
|
||||||
[Auth] Session Token Details ---
|
[Auth] Session Token Details ---
|
||||||
- Access Token Expires: $accessExp
|
- Access Token Expires: $accessExp
|
||||||
- Refresh Token Expires: $refreshExp
|
- Refresh Token Expires: $refreshExp
|
||||||
""");
|
""");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// JWT를 해석하는 과정에서 오류가 발생하면 콘솔에 에러를 출력합니다.
|
|
||||||
debugPrint("[Auth] Failed to decode or log token details: $e");
|
debugPrint("[Auth] Failed to decode or log token details: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,7 +437,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
_logTokenDetails(token);
|
_logTokenDetails(token);
|
||||||
|
|
||||||
// [FIX] 감사 로그에 실제 사용자 ID를 전송하기 위해 토큰에서 ID를 추출합니다.
|
|
||||||
final userId = _getUserIdFromJwt(token);
|
final userId = _getUserIdFromJwt(token);
|
||||||
|
|
||||||
// Record Audit Log
|
// Record Audit Log
|
||||||
@@ -449,16 +447,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
details: "User logged in via Baron SSO",
|
details: "User logged in via Baron SSO",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Handle Popup Flow (Highest Priority for child windows)
|
// 1. Handle Popup Flow
|
||||||
// If opened as a popup (has opener), we notify and try to close.
|
|
||||||
if (WebAuthIntegration.isPopup()) {
|
if (WebAuthIntegration.isPopup()) {
|
||||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
||||||
WebAuthIntegration.sendLoginSuccess(token);
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
|
|
||||||
// We don't 'return' here to allow a fallback if window.close() is blocked,
|
|
||||||
// but in most cases WebAuthIntegration.sendLoginSuccess will close the window.
|
|
||||||
} else {
|
} else {
|
||||||
// 2. Handle Redirect Flow (Only if NOT a popup)
|
// 2. Handle Redirect Flow
|
||||||
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||||
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
||||||
final target = "$_redirectUrl?token=$token";
|
final target = "$_redirectUrl?token=$token";
|
||||||
@@ -468,7 +462,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Standalone mode / Fallback
|
// 3. Standalone mode / Fallback
|
||||||
// If it's a standard login, or if a popup's window.close() was blocked by the browser.
|
|
||||||
debugPrint("[Auth] Login success. Navigating to root.");
|
debugPrint("[Auth] Login success. Navigating to root.");
|
||||||
AuthNotifier.instance.notify();
|
AuthNotifier.instance.notify();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -505,7 +498,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: "로그인"),
|
Tab(text: "이메일/전화번호"),
|
||||||
|
Tab(text: "로그인 링크"),
|
||||||
Tab(text: "QR 코드"),
|
Tab(text: "QR 코드"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -516,24 +510,68 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// Unified Login Form
|
// 1. 이메일/비밀번호 로그인 폼
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _idController,
|
controller: _passwordLoginIdController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "이메일 또는 휴대폰 번호",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "비밀번호",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _handlePasswordLogin,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
),
|
||||||
|
child: const Text("로그인"),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
_showError("비밀번호 재설정은 아직 구현되지 않았습니다.");
|
||||||
|
},
|
||||||
|
child: const Text("비밀번호를 잊으셨나요?"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 2. 로그인 링크 전송 폼
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _linkIdController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: "이메일 또는 휴대폰 번호",
|
labelText: "이메일 또는 휴대폰 번호",
|
||||||
hintText: "",
|
hintText: "",
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handleLogin(),
|
onSubmitted: (_) => _handleLinkLogin(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _handleLogin,
|
onPressed: _handleLinkLogin,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
@@ -560,7 +598,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// QR Login View
|
// 3. QR 로그인 뷰
|
||||||
Column(
|
Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
|||||||
Reference in New Issue
Block a user