diff --git a/backend/IM_API/Dtos/ClearConversationsDto.cs b/backend/IM_API/Dtos/ClearConversationsDto.cs new file mode 100644 index 0000000..0d8c5c9 --- /dev/null +++ b/backend/IM_API/Dtos/ClearConversationsDto.cs @@ -0,0 +1,26 @@ +namespace IM_API.Dtos +{ + public class ClearConversationsDto + { + public int UserId { get; set; } + /// + /// 聊天类型 + /// + public ChatType ChatType { get; set; } + /// + /// 目标ID,聊天类型为群则为群id,私聊为用户id + /// + public int TargetId { get; set; } + } + public enum ChatType + { + /// + /// 私聊 + /// + single = 0, + /// + /// 私聊 + /// + group = 1 + } +} diff --git a/backend/IM_API/Dtos/HandleFriendRequestDto.cs b/backend/IM_API/Dtos/HandleFriendRequestDto.cs new file mode 100644 index 0000000..637871d --- /dev/null +++ b/backend/IM_API/Dtos/HandleFriendRequestDto.cs @@ -0,0 +1,25 @@ +namespace IM_API.Dtos +{ + public class HandleFriendRequestDto + { + /// + /// 好友请求Id + /// + public int RequestId { get; set; } + /// + /// 处理操作 + /// + public HandleFriendRequestAction Action { get; set; } + } + public enum HandleFriendRequestAction + { + /// + /// 同意 + /// + Accept = 0, + /// + /// 拒绝 + /// + Reject = 1 + } +} diff --git a/backend/IM_API/IM_API.csproj b/backend/IM_API/IM_API.csproj index 1f6fe1f..dfcdc88 100644 --- a/backend/IM_API/IM_API.csproj +++ b/backend/IM_API/IM_API.csproj @@ -10,6 +10,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,7 +20,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/backend/IM_API/Interface/Services/IConversationService.cs b/backend/IM_API/Interface/Services/IConversationService.cs new file mode 100644 index 0000000..9c4bf63 --- /dev/null +++ b/backend/IM_API/Interface/Services/IConversationService.cs @@ -0,0 +1,21 @@ +using IM_API.Dtos; +using IM_API.Models; + +namespace IM_API.Interface.Services +{ + public interface IConversationService + { + /// + /// 清除消息会话 + /// + /// + /// + Task ClearConversationsAsync(ClearConversationsDto clearConversationsDto); + /// + /// 获取用户当前消息会话 + /// + /// 用户id + /// + Task GetConversationsAsync(int userId); + } +} diff --git a/backend/IM_API/Interface/Services/IFriendSerivce.cs b/backend/IM_API/Interface/Services/IFriendSerivce.cs index 2b5fc3a..4f0bfa2 100644 --- a/backend/IM_API/Interface/Services/IFriendSerivce.cs +++ b/backend/IM_API/Interface/Services/IFriendSerivce.cs @@ -28,5 +28,37 @@ namespace IM_API.Interface.Services /// /// Task GetFriendRequestListAsync(int userId,bool isReceived,int page,int limit); + /// + /// 处理好友请求 + /// + /// + /// + Task HandleFriendRequestAsync(HandleFriendRequestDto requestDto); + /// + /// 通过用户Id删除好友关系 + /// + /// 操作用户Id + /// 被删除用户ID + /// + Task DeleteFriendByUserIdAsync(int userId,int toUserId); + /// + /// 通过好友关系Id删除好友关系 + /// + /// 好友关系id + /// + Task DeleteFriendAsync(int friendId); + /// + /// 通过用户Id拉黑好友关系 + /// + /// 操作用户Id + /// 被拉黑用户ID + /// + Task BlockFriendByUserIdAsync(int userId, int toUserId); + /// + /// 通过好友关系Id拉黑好友关系 + /// + /// 好友关系id + /// + Task BlockeFriendAsync(int friendId); } } diff --git a/backend/IM_API/Interface/Services/IJWTService.cs b/backend/IM_API/Interface/Services/IJWTService.cs new file mode 100644 index 0000000..202d444 --- /dev/null +++ b/backend/IM_API/Interface/Services/IJWTService.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; + +namespace IM_API.Interface.Services +{ + public interface IJWTService + { + /// + /// 生成用户凭证 + /// + /// 负载 + /// 过期时间 + /// + string GenerateAccessToken(IEnumerable claims, DateTime expiresAt); + /// + /// 创建用户凭证 + /// + /// + /// + (string token, DateTime expiresAt) CreateAccessTokenForUser(int userId,string username,string role); + } +} diff --git a/backend/IM_API/Interface/Services/IRefreshTokenService.cs b/backend/IM_API/Interface/Services/IRefreshTokenService.cs new file mode 100644 index 0000000..3133fc9 --- /dev/null +++ b/backend/IM_API/Interface/Services/IRefreshTokenService.cs @@ -0,0 +1,27 @@ +namespace IM_API.Interface.Services +{ + public interface IRefreshTokenService + { + /// + /// 创建刷新令牌 + /// + /// + /// + /// + Task CreateRefreshTokenAsync(int userId, CancellationToken ct = default); + /// + /// 验证刷新令牌 + /// + /// 刷新令牌 + /// + /// + Task<(bool ok, int userId)> ValidateRefreshTokenAsync(string token, CancellationToken ct = default); + /// + /// 删除更新令牌 + /// + /// 刷新令牌 + /// + /// + Task RevokeRefreshTokenAsync(string token, CancellationToken ct = default); + } +} diff --git a/backend/IM_API/Program.cs b/backend/IM_API/Program.cs index 2ac6eae..52e576e 100644 --- a/backend/IM_API/Program.cs +++ b/backend/IM_API/Program.cs @@ -1,7 +1,11 @@ using IM_API.Configs; using IM_API.Models; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using StackExchange.Redis; +using System.Text; namespace IM_API { @@ -16,14 +20,74 @@ namespace IM_API .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); - string conStr = builder.Configuration.GetConnectionString("DefaultConnection")!; - + string conStr = configuration.GetConnectionString("DefaultConnection")!; + string redisConStr = configuration.GetConnectionString("Redis"); + //עݿ builder.Services.AddDbContext(options => { options.UseMySql(conStr,ServerVersion.AutoDetect(conStr)); }); + //עredis + var redis = ConnectionMultiplexer.Connect(redisConStr); + builder.Services.AddSingleton(redis); builder.Services.AddAllService(configuration); + + builder.Services.AddSignalR(); + //Դ + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyHeader() + .AllowAnyMethod() + .AllowAnyHeader() + .AllowAnyOrigin() + .AllowAnyOrigin(); + }); + }); + //ƾ֤ + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + //httpsDZ + options.RequireHttpsMetadata = false; + //token + options.SaveToken = true; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + //֤ǩ + ValidateIssuer = true, + ValidIssuer = configuration["Jwt:Issuer"], + //֤ + ValidateAudience = true, + ValidAudience = configuration["Jwt:Audience"], + //֤ǩԿ + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"])), + //ʱƫ + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + + + }; + //websocket tokenƾ֤ + options.Events = new JwtBearerEvents { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken)) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; + }); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -41,6 +105,7 @@ namespace IM_API app.UseHttpsRedirection(); app.UseAuthorization(); + app.UseAuthentication(); app.MapControllers(); diff --git a/backend/IM_API/Services/FriendService.cs b/backend/IM_API/Services/FriendService.cs index 82cb9a1..0ef256c 100644 --- a/backend/IM_API/Services/FriendService.cs +++ b/backend/IM_API/Services/FriendService.cs @@ -1,6 +1,10 @@ -namespace IM_API.Services +using IM_API.Models; +using Microsoft.EntityFrameworkCore; + +namespace IM_API.Services { public class FriendService { + } } diff --git a/backend/IM_API/Services/JWTService.cs b/backend/IM_API/Services/JWTService.cs new file mode 100644 index 0000000..f2be501 --- /dev/null +++ b/backend/IM_API/Services/JWTService.cs @@ -0,0 +1,55 @@ +using IM_API.Interface.Services; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace IM_API.Services +{ + public class JWTService : IJWTService + { + private readonly IConfiguration _config; + private readonly string _key; + private readonly string _issuer; + private readonly string _audience; + private readonly int _accessMinutes; + + public JWTService(IConfiguration config) + { + _config = config; + _key = _config["Jwt:Key"]!; + _issuer = _config["Jwt:Issuer"]!; + _audience = _config["Jwt:Audience"]!; + _accessMinutes = int.Parse(_config["Jwt:AccessTokenMinutes"] ?? "15"); + } + + public string GenerateAccessToken(IEnumerable claims, DateTime expiresAt) + { + var keyBytes = Encoding.UTF8.GetBytes(_key); + var creds = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _issuer, + audience: _audience, + claims: claims, + expires: expiresAt, + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public (string token, DateTime expiresAt) CreateAccessTokenForUser(int userId, string username, string role) + { + var expiresAt = DateTime.UtcNow.AddMinutes(_accessMinutes); + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(ClaimTypes.Name, username), + new Claim(ClaimTypes.Role, role) + }; + var token = GenerateAccessToken(claims, expiresAt); + return (token, expiresAt); + } + } +} diff --git a/backend/IM_API/Services/RedisRefreshTokenService.cs b/backend/IM_API/Services/RedisRefreshTokenService.cs new file mode 100644 index 0000000..260041d --- /dev/null +++ b/backend/IM_API/Services/RedisRefreshTokenService.cs @@ -0,0 +1,66 @@ +using IM_API.Interface.Services; +using Microsoft.AspNetCore.Connections; +using Newtonsoft.Json; +using StackExchange.Redis; +using System.Numerics; +using System.Security.Cryptography; +using System.Text.Json; + +namespace IM_API.Services +{ + public class RedisRefreshTokenService : IRefreshTokenService + { + private readonly ILogger _logger; + //redis数据库 + private readonly IDatabase _db; + private IConfiguration configuration; + //过期时长 + private readonly TimeSpan _refreshTTL; + public RedisRefreshTokenService(ILogger logger, IConnectionMultiplexer multiplexer, IConfiguration configuration) + { + _logger = logger; + _db = multiplexer.GetDatabase(); + this.configuration = configuration; + //设置refresh过期时间 + var days = int.Parse(this.configuration["Jwt:RefreshTokenDays"] ?? "30"); + _refreshTTL = TimeSpan.FromDays(days); + } + + private static string GenerateTokenStr() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes); + } + + public async Task CreateRefreshTokenAsync(int userId, CancellationToken ct = default) + { + string token = GenerateTokenStr(); + var payload = new { UserId = userId,CreateAt = DateTime.Now}; + string json = JsonConvert.SerializeObject(payload); + //token写入redis + await _db.StringSetAsync(token,json,_refreshTTL); + return token; + } + + public async Task RevokeRefreshTokenAsync(string token, CancellationToken ct = default) + { + await _db.KeyDeleteAsync(token); + } + + public async Task<(bool ok, int userId)> ValidateRefreshTokenAsync(string token, CancellationToken ct = default) + { + var json = await _db.StringGetAsync(token); + if (json.IsNullOrEmpty) return (false,-1); + try + { + var doc = JsonConvert.DeserializeObject(json); + var userId = doc.GetProperty("UserId").GetInt32(); + return (true,userId); + } + catch + { + return (false,-1); + } + } + } +} diff --git a/backend/IM_API/appsettings.json b/backend/IM_API/appsettings.json index d6e1b90..0b5706a 100644 --- a/backend/IM_API/appsettings.json +++ b/backend/IM_API/appsettings.json @@ -6,7 +6,15 @@ } }, "AllowedHosts": "*", + "Jwt": { + "Key": "change_this_super_secret_key_in_prod", + "Issuer": "IMDemo", + "Audience": "IMClients", + "AccessTokenMinutes": 15, + "RefreshTokenDays": 30 + }, "ConnectionStrings": { - "DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;" + "DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;", + "Redis": "192.168.5.100:6379" } } diff --git a/frontend/web/src/App.vue b/frontend/web/src/App.vue index 59f2b39..4550d52 100644 --- a/frontend/web/src/App.vue +++ b/frontend/web/src/App.vue @@ -3,10 +3,8 @@ - - diff --git a/frontend/web/src/components/Window.vue b/frontend/web/src/components/Window.vue index 874c1aa..f8678e5 100644 --- a/frontend/web/src/components/Window.vue +++ b/frontend/web/src/components/Window.vue @@ -23,12 +23,25 @@ - + - + @@ -42,9 +55,15 @@ \ No newline at end of file + diff --git a/frontend/web/src/views/auth/Login.vue b/frontend/web/src/views/auth/Login.vue index f5667df..b2c7d98 100644 --- a/frontend/web/src/views/auth/Login.vue +++ b/frontend/web/src/views/auth/Login.vue @@ -6,10 +6,22 @@ - + - - + + 即时通讯 @@ -34,19 +46,55 @@ - - - + + + - + - - + + - + @@ -126,9 +174,9 @@ const handleLogin = () => { display: flex; border-radius: 12px; overflow: hidden; - background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.98)); - box-shadow: 0 18px 40px rgba(16,24,40,0.08); - border: 1px solid rgba(15,23,42,0.04); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)); + box-shadow: 0 18px 40px rgba(16, 24, 40, 0.08); + border: 1px solid rgba(15, 23, 42, 0.04); } /* 左侧品牌区域 */ @@ -136,129 +184,271 @@ const handleLogin = () => { width: 44%; min-width: 280px; padding: 28px 22px; - background: linear-gradient(160deg, rgba(59,130,246,0.06), rgba(99,102,241,0.04)); + background: linear-gradient(160deg, rgba(59, 130, 246, 0.06), rgba(99, 102, 241, 0.04)); display: flex; flex-direction: column; justify-content: center; position: relative; } -.brand { display:flex; align-items:center; gap:12px; margin-bottom:12px; } -.brand-logo { width:56px; height:56px; display:flex; align-items:center; justify-content:center; color: #3b82f6; background: rgba(59,130,246,0.08); border-radius:12px; } -.brand-name { font-size:18px; font-weight:700; color:#0f172a; } +.brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.brand-logo { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + color: #3b82f6; + background: rgba(59, 130, 246, 0.08); + border-radius: 12px; +} +.brand-name { + font-size: 18px; + font-weight: 700; + color: #0f172a; +} -.side-desc { color:#475569; font-size:13px; line-height:1.5; max-width:210px; margin-bottom:18px; } +.side-desc { + color: #475569; + font-size: 13px; + line-height: 1.5; + max-width: 210px; + margin-bottom: 18px; +} /* 简单的装饰圆块 */ -.side-illu { position:absolute; right:-30px; bottom:-20px; } -.bubble { border-radius:50%; opacity:0.12; } -.b1 { width:120px; height:120px; background: linear-gradient(135deg,#60a5fa,#7c3aed); transform: rotate(10deg); margin:8px; } -.b2 { width:72px; height:72px; background: linear-gradient(135deg,#a78bfa,#60a5fa); margin:8px; } -.b3 { width:40px; height:40px; background: linear-gradient(135deg,#93c5fd,#a78bfa); margin:8px; } +.side-illu { + position: absolute; + right: -30px; + bottom: -20px; +} +.bubble { + border-radius: 50%; + opacity: 0.12; +} +.b1 { + width: 120px; + height: 120px; + background: linear-gradient(135deg, #60a5fa, #7c3aed); + transform: rotate(10deg); + margin: 8px; +} +.b2 { + width: 72px; + height: 72px; + background: linear-gradient(135deg, #a78bfa, #60a5fa); + margin: 8px; +} +.b3 { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #93c5fd, #a78bfa); + margin: 8px; +} /* 右侧表单 */ .login-body { width: 56%; padding: 30px 34px; - display:flex; - flex-direction:column; - justify-content:space-between; + display: flex; + flex-direction: column; + justify-content: space-between; } -.login-header { text-align:left; margin-bottom:6px; } -.login-title { font-size:20px; margin:0 0 6px; color:#0f172a; font-weight:700; } -.login-subtitle { margin:0; font-size:13px; color:#64748b; font-weight:500; } +.login-header { + text-align: left; + margin-bottom: 6px; +} +.login-title { + font-size: 20px; + margin: 0 0 6px; + color: #0f172a; + font-weight: 700; +} +.login-subtitle { + margin: 0; + font-size: 13px; + color: #64748b; + font-weight: 500; +} /* 表单 */ -.login-form { margin-top:10px; display:flex; flex-direction:column; gap:14px; } +.login-form { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 14px; +} /* 输入容器 */ .input-container { - position:relative; - display:flex; - align-items:center; + position: relative; + display: flex; + align-items: center; background: #ffffff; - border-radius:10px; - padding:12px 12px 12px 44px; - border:1px solid rgba(15,23,42,0.06); - box-shadow: 0 6px 18px rgba(15,23,42,0.02); - transition: all .22s ease; + border-radius: 10px; + padding: 12px 12px 12px 44px; + border: 1px solid rgba(15, 23, 42, 0.06); + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.02); + transition: all 0.22s ease; } .input-container .icon { - position:absolute; - left:12px; - width:18px; height:18px; - color:#94a3b8; - opacity:0.95; + position: absolute; + left: 12px; + width: 18px; + height: 18px; + color: #94a3b8; + opacity: 0.95; } .input-container input { - width:100%; - border:none; - outline:none; - font-size:14px; - color:#0f172a; + width: 100%; + border: none; + outline: none; + font-size: 14px; + color: #0f172a; background: transparent; - padding:0; + padding: 0; } /* 聚焦态 */ .input-container.focused { - border-color: rgba(99,102,241,0.9); - box-shadow: 0 8px 20px rgba(99,102,241,0.08); + border-color: rgba(99, 102, 241, 0.9); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.08); transform: translateY(-2px); } -.input-container.focused .icon { color: rgba(99,102,241,0.95); } +.input-container.focused .icon { + color: rgba(99, 102, 241, 0.95); +} /* 行内布局 */ -.row-between { display:flex; justify-content:space-between; align-items:center; font-size:13px; margin-top:2px; } +.row-between { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + margin-top: 2px; +} /* 记住我 checkbox */ -.remember { display:flex; align-items:center; gap:8px; color:#475569; cursor:pointer; user-select:none; } -.remember input { position:absolute; opacity:0; pointer-events:none; } -.remember .box { width:18px; height:18px; display:inline-block; border-radius:6px; border:1.5px solid rgba(15,23,42,0.08); background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,0.03); transition:all .14s ease; } -.remember input:checked + .box { background: linear-gradient(90deg,#6366f1,#3b82f6); border-color:transparent; box-shadow:none; } -.remember input:checked + .box::after { content:""; display:block; width:6px; height:10px; border:2px solid white; border-left:0; border-top:0; transform: translate(5px,2px) rotate(45deg); } +.remember { + display: flex; + align-items: center; + gap: 8px; + color: #475569; + cursor: pointer; + user-select: none; +} +.remember input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.remember .box { + width: 18px; + height: 18px; + display: inline-block; + border-radius: 6px; + border: 1.5px solid rgba(15, 23, 42, 0.08); + background: white; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.03); + transition: all 0.14s ease; +} +.remember input:checked + .box { + background: linear-gradient(90deg, #6366f1, #3b82f6); + border-color: transparent; + box-shadow: none; +} +.remember input:checked + .box::after { + content: ''; + display: block; + width: 6px; + height: 10px; + border: 2px solid white; + border-left: 0; + border-top: 0; + transform: translate(5px, 2px) rotate(45deg); +} /* 链接样式 */ -.link { color:#3b82f6; text-decoration:none; font-weight:600; } -.link:hover { text-decoration:underline; color:#2563eb; } +.link { + color: #3b82f6; + text-decoration: none; + font-weight: 600; +} +.link:hover { + text-decoration: underline; + color: #2563eb; +} /* 主操作按钮 */ .btn-primary { - width:100%; - padding:12px 14px; - border-radius:10px; - border:none; - background: linear-gradient(90deg,#6366f1,#3b82f6); - color:white; - font-weight:700; - font-size:15px; - cursor:pointer; - box-shadow: 0 8px 20px rgba(59,130,246,0.18); - transition: transform .12s ease, box-shadow .12s ease, opacity .12s; + width: 100%; + padding: 12px 14px; + border-radius: 10px; + border: none; + background: linear-gradient(90deg, #6366f1, #3b82f6); + color: white; + font-weight: 700; + font-size: 15px; + cursor: pointer; + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.18); + transition: + transform 0.12s ease, + box-shadow 0.12s ease, + opacity 0.12s; +} +.btn-primary:active { + transform: translateY(0); +} +.btn-primary:disabled { + opacity: 0.6; + cursor: default; + box-shadow: none; } -.btn-primary:active { transform: translateY(0); } -.btn-primary:disabled { opacity:0.6; cursor:default; box-shadow:none; } /* 错误提示 */ .error { - color:#b91c1c; + color: #b91c1c; background: #fff5f5; - border-left:4px solid #f87171; - padding:10px 12px; - border-radius:8px; - font-weight:600; - font-size:13px; - margin-top:6px; + border-left: 4px solid #f87171; + padding: 10px 12px; + border-radius: 8px; + font-weight: 600; + font-size: 13px; + margin-top: 6px; } /* 页脚注册 */ -.login-footer { display:flex; gap:8px; align-items:center; justify-content:flex-end; font-size:13px; color:#475569; } +.login-footer { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + font-size: 13px; + color: #475569; +} /* 响应式 */ @media (max-width: 820px) { - .login-card { flex-direction:column; height:auto; width:100%; } - .login-side { width:100%; min-height:140px; order:1; } - .login-body { width:100%; order:2; padding:22px; } + .login-card { + flex-direction: column; + height: auto; + width: 100%; + } + .login-side { + width: 100%; + min-height: 140px; + order: 1; + } + .login-body { + width: 100%; + order: 2; + padding: 22px; + } }